Merge branch 'ci', enabling CI services.
diff --git a/.classpath b/.classpath
index ccf6a4e..095cb03 100644
--- a/.classpath
+++ b/.classpath
@@ -5,6 +5,7 @@
 	<classpathentry kind="src" path="src/test/java" output="bin/test-classes" />
 	<classpathentry kind="src" path="src/test/bugtraq" output="bin/test-classes" />
 	<classpathentry kind="src" path="src/main/resources" />
+	<classpathentry kind="src" path="src/test/resources" output="bin/test-classes" />
 	<classpathentry kind="lib" path="ext/guice-4.0.jar" sourcepath="ext/src/guice-4.0.jar" />
 	<classpathentry kind="lib" path="ext/javax.inject-1.jar" sourcepath="ext/src/javax.inject-1.jar" />
 	<classpathentry kind="lib" path="ext/aopalliance-1.0.jar" sourcepath="ext/src/aopalliance-1.0.jar" />
@@ -79,7 +80,7 @@
 	<classpathentry kind="lib" path="ext/pf4j-0.9.0.jar" sourcepath="ext/src/pf4j-0.9.0.jar" />
 	<classpathentry kind="lib" path="ext/tika-core-1.5.jar" sourcepath="ext/src/tika-core-1.5.jar" />
 	<classpathentry kind="lib" path="ext/jsoup-1.7.3.jar" sourcepath="ext/src/jsoup-1.7.3.jar" />
-	<classpathentry kind="lib" path="ext/junit-4.11.jar" sourcepath="ext/src/junit-4.11.jar" />
+	<classpathentry kind="lib" path="ext/junit-4.12.jar" sourcepath="ext/src/junit-4.12.jar" />
 	<classpathentry kind="lib" path="ext/hamcrest-core-1.3.jar" sourcepath="ext/src/hamcrest-core-1.3.jar" />
 	<classpathentry kind="lib" path="ext/selenium-java-2.28.0.jar" sourcepath="ext/src/selenium-java-2.28.0.jar" />
 	<classpathentry kind="lib" path="ext/selenium-support-2.28.0.jar" sourcepath="ext/src/selenium-support-2.28.0.jar" />
diff --git a/build.moxie b/build.moxie
index e1dbdd5..07208cc 100644
--- a/build.moxie
+++ b/build.moxie
@@ -10,7 +10,7 @@
 description: pure Java Git solution
 groupId: com.gitblit
 artifactId: gitblit
-version: 1.8.0
+version: 1.8.1-SNAPSHOT
 inceptionYear: 2011
 
 # Current stable release
@@ -66,6 +66,7 @@
 
 resourceDirectories:
 - compile 'src/main/resources'
+- test 'src/test/resources'
 - site 'src/site/resources'
 
 # compile for Java 7 class format
@@ -180,7 +181,7 @@
 - compile 'ro.fortsoft.pf4j:pf4j:0.9.0' :war
 - compile 'org.apache.tika:tika-core:1.5' :war
 - compile 'org.jsoup:jsoup:1.7.3' :war
-- test 'junit'
+- test 'junit:junit:4.12'
 # Dependencies for Selenium web page testing
 - test 'org.seleniumhq.selenium:selenium-java:${selenium.version}' @jar
 - test 'org.seleniumhq.selenium:selenium-support:${selenium.version}' @jar
@@ -188,4 +189,4 @@
 - test 'org.mockito:mockito-core:1.10.19'
 # Dependencies with the "build" scope are retrieved
 # and injected into the Ant runtime classpath
-- build 'jacoco'
+- build 'org.jacoco:org.jacoco.ant:0.7.8'
diff --git a/gitblit.iml b/gitblit.iml
index 93331b2..71907a5 100644
--- a/gitblit.iml
+++ b/gitblit.iml
@@ -11,6 +11,7 @@
       <sourceFolder url="file://$MODULE_DIR$/src/test/java" isTestSource="true" />
       <sourceFolder url="file://$MODULE_DIR$/src/test/bugtraq" isTestSource="true" />
       <sourceFolder url="file://$MODULE_DIR$/src/main/resources" isTestSource="false" />
+      <sourceFolder url="file://$MODULE_DIR$/src/test/resources" isTestSource="true" />
     </content>
     <orderEntry type="sourceFolder" forTests="false" />
     <orderEntry type="module-library">
@@ -822,13 +823,13 @@
       </library>
     </orderEntry>
     <orderEntry type="module-library" scope="TEST">
-      <library name="junit-4.11.jar">
+      <library name="junit-4.12.jar">
         <CLASSES>
-          <root url="jar://$MODULE_DIR$/ext/junit-4.11.jar!/" />
+          <root url="jar://$MODULE_DIR$/ext/junit-4.12.jar!/" />
         </CLASSES>
         <JAVADOC />
         <SOURCES>
-          <root url="jar://$MODULE_DIR$/ext/src/junit-4.11.jar!/" />
+          <root url="jar://$MODULE_DIR$/ext/src/junit-4.12.jar!/" />
         </SOURCES>
       </library>
     </orderEntry>
diff --git a/releases.moxie b/releases.moxie
index 554028a..be1200e 100644
--- a/releases.moxie
+++ b/releases.moxie
@@ -1,4 +1,22 @@
 #
+# ${project.version} release
+#
+r30: {
+    title: ${project.name} ${project.version} released
+    id: ${project.version}
+    date: ${project.buildDate}
+    note: ~
+    html: ~
+    text: ~
+    security: ~
+    fixes: ~
+    changes: ~
+    additions: ~
+    dependencyChanges: ~
+    contributors: ~
+}
+
+#
 # 1.8.0 release
 #
 r29: {
@@ -1832,6 +1850,6 @@
 	- James Moger
 }
 
-snapshot: ~
+snapshot: &r30
 release: &r29
 releases: &r[1..29]
diff --git a/src/main/distrib/data/defaults.properties b/src/main/distrib/data/defaults.properties
index 0c7d6cd..9bb0248 100644
--- a/src/main/distrib/data/defaults.properties
+++ b/src/main/distrib/data/defaults.properties
@@ -301,7 +301,7 @@
 # the repository should be created with 'git init --shared' to make sure that
 # it can be accessed e.g. via ssh (user git) and http (user www-data).
 #
-# Valid values are the values available for the '--shared' option. The the manual
+# Valid values are the values available for the '--shared' option. See the manual
 # page for 'git init' for more information on shared repositories.
 #
 # SINCE 1.4.0
@@ -567,6 +567,21 @@
 # SINCE 1.4.0
 tickets.requireApproval = false
 
+# Default setting to control how patchsets are merged to the integration branch.
+# Valid values: 
+# MERGE_ALWAYS       - Always merge with a merge commit. Every ticket will show up as a branch,
+#                       even if it could have been fast-forward merged. This is the default.
+# MERGE_IF_NECESSARY - If possible, fast-forward the integration branch,
+#                       if not, merge with a merge commit.
+# FAST_FORWARD_ONLY  - Only merge when a fast-forward is possible. This produces a strictly
+#                       linear history of the integration branch.
+#
+# This setting can be overriden per-repository.
+#
+# RESTART REQUIRED
+# SINCE 1.9.0
+tickets.mergeType = MERGE_ALWAYS
+
 # The case-insensitive regular expression used to identify and close tickets on
 # push to the integration branch for commits that are NOT already referenced as
 # a patchset tip.
@@ -1797,6 +1812,10 @@
 realm.ldap.server = ldap://localhost
 
 # Login username for LDAP searches.
+# This is usually a user with permissions to search LDAP users and groups.
+# It must have at least have the permission to search users. If it does not
+# have permission to search groups, the normal user logging in must have
+# the permission in LDAP to search groups.
 # If this value is unspecified, anonymous LDAP login will be used.
 # 
 # e.g. mydomain\\username
@@ -1809,8 +1828,14 @@
 # SINCE 1.0.0
 realm.ldap.password = password
 
-# Bind pattern for Authentication.
-# Allow to directly authenticate an user without LDAP Searches.
+# Bind pattern for user authentication.
+# Allow to directly authenticate an user without searching for it in LDAP.
+# Use this if the LDAP server does not allow anonymous access and you don't
+# want to use a specific account to run searches. When set, it will override
+# the settings realm.ldap.username and realm.ldap.password.
+# This requires that all relevant user entries are children to the same DN,
+# and that logging users have permission to search for their groups in LDAP.
+# This will disable synchronization as a specific LDAP account is needed for that.
 # 
 # e.g. CN=${username},OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain
 #
@@ -1925,7 +1950,26 @@
 # SINCE 1.0.0
 realm.ldap.uid = uid
 
+# Attribute on the USER record that indicates their public SSH key.
+# Leave blank when public SSH keys shall not be retrieved from LDAP.
+#
+# This setting is only relevant when a public key manager is used that
+# retrieves SSH keys from LDAP (e.g. com.gitblit.transport.ssh.LdapKeyManager).
+#
+# The accepted format of the value is dependent on the public key manager used.
+# Examples:
+#  sshPublicKey - Use the attribute 'sshPublicKey' on the user record.
+#  altSecurityIdentities:SshKey - Use the attribute 'altSecurityIdentities'
+#                                 on the user record, for which the record value
+#                                 starts with 'SshKey:', followed by the SSH key entry.
+#
+# SINCE 1.9.0
+realm.ldap.sshPublicKey =
+
 # Defines whether to synchronize all LDAP users and teams into the user service
+# This requires either anonymous LDAP access or that a specific account is set
+# in realm.ldap.username and realm.ldap.password, that has permission to read
+# users and groups in LDAP.
 #
 # Valid values: true, false
 # If left blank, false is assumed
diff --git a/src/main/distrib/data/groovy/sendmail-html.groovy b/src/main/distrib/data/groovy/sendmail-html.groovy
index 2692556..305c640 100644
--- a/src/main/distrib/data/groovy/sendmail-html.groovy
+++ b/src/main/distrib/data/groovy/sendmail-html.groovy
@@ -336,7 +336,7 @@
         }

         builder.td() {

             mkp.yield header.oldPath

-			mkp.yieldUnescaped "<b> -&rt; </b>"

+			mkp.yieldUnescaped "<b> -&gt; </b>"

 			a(href:blobDiffUrl(id, header.newPath),  header.newPath)

         }

     }

diff --git a/src/main/java/com/gitblit/ConfigUserService.java b/src/main/java/com/gitblit/ConfigUserService.java
index 6d7230f..025b1d8 100644
--- a/src/main/java/com/gitblit/ConfigUserService.java
+++ b/src/main/java/com/gitblit/ConfigUserService.java
@@ -898,7 +898,7 @@
 					user.countryCode = config.getString(USER, username, COUNTRYCODE);

 					user.cookie = config.getString(USER, username, COOKIE);

 					if (StringUtils.isEmpty(user.cookie) && !StringUtils.isEmpty(user.password)) {

-						user.cookie = StringUtils.getSHA1(user.username + user.password);

+						user.cookie = user.createCookie();

 					}

 

 					// preferences

diff --git a/src/main/java/com/gitblit/Constants.java b/src/main/java/com/gitblit/Constants.java
index 6232552..ab503bd 100644
--- a/src/main/java/com/gitblit/Constants.java
+++ b/src/main/java/com/gitblit/Constants.java
@@ -62,6 +62,12 @@
 	public static final String GIT_PATH = "/git/";

 	

 	public static final String REGEX_SHA256 = "[a-fA-F0-9]{64}";

+	

+	/**

+	 * This regular expression is used when searching for "mentions" in tickets

+	 * (when someone writes @thisOtherUser)

+	 */

+	public static final String REGEX_TICKET_MENTION = "\\B@(?<user>[^\\s]+)\\b";

 

 	public static final String ZIP_PATH = "/zip/";

 

@@ -639,6 +645,37 @@
 		}

 	}

 

+	/**

+	 * The type of merge Gitblit will use when merging a ticket to the integration branch.

+	 * <p>

+	 * The default type is MERGE_ALWAYS.

+	 * <p>

+	 * This is modeled after the Gerrit SubmitType.

+	 */

+	public static enum MergeType {

+		/** Allows a merge only if it can be fast-forward merged into the integration branch. */

+		FAST_FORWARD_ONLY,

+		/** Uses a fast-forward merge if possible, other wise a merge commit is created. */

+		MERGE_IF_NECESSARY,

+		// Future REBASE_IF_NECESSARY,

+		/** Always merge with a merge commit, even when a fast-forward would be possible. */

+		MERGE_ALWAYS,

+		// Future? CHERRY_PICK

+		;

+

+		public static final MergeType DEFAULT_MERGE_TYPE = MERGE_ALWAYS;

+

+		public static MergeType fromName(String name) {

+			for (MergeType type : values()) {

+				if (type.name().equalsIgnoreCase(name)) {

+					return type;

+				}

+			}

+			return DEFAULT_MERGE_TYPE;

+		}

+	}

+

+

 	@Documented

 	@Retention(RetentionPolicy.RUNTIME)

 	public @interface Unused {

diff --git a/src/main/java/com/gitblit/GitBlitServer.java b/src/main/java/com/gitblit/GitBlitServer.java
index d56d9c0..6123a87 100644
--- a/src/main/java/com/gitblit/GitBlitServer.java
+++ b/src/main/java/com/gitblit/GitBlitServer.java
@@ -375,7 +375,8 @@
 		HashSessionManager sessionManager = new HashSessionManager();
 		sessionManager.setHttpOnly(true);
 		// Use secure cookies if only serving https
-		sessionManager.setSecureRequestOnly(params.port <= 0 && params.securePort > 0);
+		sessionManager.setSecureRequestOnly( (params.port <= 0 && params.securePort > 0) ||
+				(params.port > 0 && params.securePort > 0 && settings.getBoolean(Keys.server.redirectToHttpsPort, true)) );
 		rootContext.getSessionHandler().setSessionManager(sessionManager);
 
 		// Ensure there is a defined User Service
diff --git a/src/main/java/com/gitblit/auth/AuthenticationProvider.java b/src/main/java/com/gitblit/auth/AuthenticationProvider.java
index 0bfe235..e359fd7 100644
--- a/src/main/java/com/gitblit/auth/AuthenticationProvider.java
+++ b/src/main/java/com/gitblit/auth/AuthenticationProvider.java
@@ -78,10 +78,10 @@
 
 	public abstract AuthenticationType getAuthenticationType();
 
-	protected void setCookie(UserModel user, char [] password) {
+	protected void setCookie(UserModel user) {
 		// create a user cookie
-		if (StringUtils.isEmpty(user.cookie) && !ArrayUtils.isEmpty(password)) {
-			user.cookie = StringUtils.getSHA1(user.username + new String(password));
+		if (StringUtils.isEmpty(user.cookie)) {
+			user.cookie = user.createCookie();
 		}
 	}
 
diff --git a/src/main/java/com/gitblit/auth/HtpasswdAuthProvider.java b/src/main/java/com/gitblit/auth/HtpasswdAuthProvider.java
index 2cdabf6..3a6cb8e 100644
--- a/src/main/java/com/gitblit/auth/HtpasswdAuthProvider.java
+++ b/src/main/java/com/gitblit/auth/HtpasswdAuthProvider.java
@@ -196,7 +196,7 @@
                 }
 
                 // create a user cookie
-                setCookie(user, password);
+                setCookie(user);
 
                 // Set user attributes, hide password from backing user service.
                 user.password = Constants.EXTERNAL_ACCOUNT;
diff --git a/src/main/java/com/gitblit/auth/LdapAuthProvider.java b/src/main/java/com/gitblit/auth/LdapAuthProvider.java
index cc772e7..6a2cbde 100644
--- a/src/main/java/com/gitblit/auth/LdapAuthProvider.java
+++ b/src/main/java/com/gitblit/auth/LdapAuthProvider.java
@@ -16,9 +16,6 @@
  */
 package com.gitblit.auth;
 
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.security.GeneralSecurityException;
 import java.text.MessageFormat;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -33,26 +30,20 @@
 import com.gitblit.Constants.Role;
 import com.gitblit.Keys;
 import com.gitblit.auth.AuthenticationProvider.UsernamePasswordAuthenticationProvider;
+import com.gitblit.ldap.LdapConnection;
 import com.gitblit.models.TeamModel;
 import com.gitblit.models.UserModel;
 import com.gitblit.service.LdapSyncService;
 import com.gitblit.utils.ArrayUtils;
 import com.gitblit.utils.StringUtils;
 import com.unboundid.ldap.sdk.Attribute;
-import com.unboundid.ldap.sdk.DereferencePolicy;
-import com.unboundid.ldap.sdk.ExtendedResult;
-import com.unboundid.ldap.sdk.LDAPConnection;
+import com.unboundid.ldap.sdk.BindResult;
 import com.unboundid.ldap.sdk.LDAPException;
-import com.unboundid.ldap.sdk.LDAPSearchException;
 import com.unboundid.ldap.sdk.ResultCode;
 import com.unboundid.ldap.sdk.SearchRequest;
 import com.unboundid.ldap.sdk.SearchResult;
 import com.unboundid.ldap.sdk.SearchResultEntry;
 import com.unboundid.ldap.sdk.SearchScope;
-import com.unboundid.ldap.sdk.SimpleBindRequest;
-import com.unboundid.ldap.sdk.extensions.StartTLSExtendedRequest;
-import com.unboundid.util.ssl.SSLUtil;
-import com.unboundid.util.ssl.TrustAllTrustManager;
 
 /**
  * Implementation of an LDAP user service.
@@ -107,12 +98,18 @@
 		if (enabled) {
 			logger.info("Synchronizing with LDAP @ " + settings.getRequiredString(Keys.realm.ldap.server));
 			final boolean deleteRemovedLdapUsers = settings.getBoolean(Keys.realm.ldap.removeDeletedUsers, true);
-			LDAPConnection ldapConnection = getLdapConnection();
-			if (ldapConnection != null) {
+			LdapConnection ldapConnection = new LdapConnection(settings);
+			if (ldapConnection.connect()) {
+				if (ldapConnection.bind() == null) {
+					ldapConnection.close();
+					logger.error("Cannot synchronize with LDAP.");
+					return;
+				}
+
 				try {
-					String accountBase = settings.getString(Keys.realm.ldap.accountBase, "");
 					String uidAttribute = settings.getString(Keys.realm.ldap.uid, "uid");
-					String accountPattern = settings.getString(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))");
+					String accountBase = ldapConnection.getAccountBase();
+					String accountPattern = ldapConnection.getAccountPattern();
 					accountPattern = StringUtils.replace(accountPattern, "${username}", "*");
 
 					SearchResult result = doSearch(ldapConnection, accountBase, accountPattern);
@@ -163,6 +160,8 @@
 							final Map<String, TeamModel> userTeams = new HashMap<String, TeamModel>();
 							for (UserModel user : ldapUsers.values()) {
 								for (TeamModel userTeam : user.teams) {
+									// Is this an administrative team?
+									setAdminAttribute(userTeam);
 									userTeams.put(userTeam.name, userTeam);
 								}
 							}
@@ -179,66 +178,6 @@
 		}
 	}
 
-	private LDAPConnection getLdapConnection() {
-		try {
-
-			URI ldapUrl = new URI(settings.getRequiredString(Keys.realm.ldap.server));
-			String ldapHost = ldapUrl.getHost();
-			int ldapPort = ldapUrl.getPort();
-			String bindUserName = settings.getString(Keys.realm.ldap.username, "");
-			String bindPassword = settings.getString(Keys.realm.ldap.password, "");
-
-			LDAPConnection conn;
-			if (ldapUrl.getScheme().equalsIgnoreCase("ldaps")) {
-				// SSL
-				SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager());
-				conn = new LDAPConnection(sslUtil.createSSLSocketFactory());
-				if (ldapPort == -1) {
-					ldapPort = 636;
-				}
-			} else if (ldapUrl.getScheme().equalsIgnoreCase("ldap") || ldapUrl.getScheme().equalsIgnoreCase("ldap+tls")) {
-				// no encryption or StartTLS
-				conn = new LDAPConnection();
-				 if (ldapPort == -1) {
-					 ldapPort = 389;
-				 }
-			} else {
-				logger.error("Unsupported LDAP URL scheme: " + ldapUrl.getScheme());
-				return null;
-			}
-
-			conn.connect(ldapHost, ldapPort);
-
-			if (ldapUrl.getScheme().equalsIgnoreCase("ldap+tls")) {
-				SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager());
-				ExtendedResult extendedResult = conn.processExtendedOperation(
-						new StartTLSExtendedRequest(sslUtil.createSSLContext()));
-				if (extendedResult.getResultCode() != ResultCode.SUCCESS) {
-					throw new LDAPException(extendedResult.getResultCode());
-				}
-			}
-
-			if (StringUtils.isEmpty(bindUserName) && StringUtils.isEmpty(bindPassword)) {
-				// anonymous bind
-				conn.bind(new SimpleBindRequest());
-			} else {
-				// authenticated bind
-				conn.bind(new SimpleBindRequest(bindUserName, bindPassword));
-			}
-
-			return conn;
-
-		} catch (URISyntaxException e) {
-			logger.error("Bad LDAP URL, should be in the form: ldap(s|+tls)://<server>:<port>", e);
-		} catch (GeneralSecurityException e) {
-			logger.error("Unable to create SSL Connection", e);
-		} catch (LDAPException e) {
-			logger.error("Error Connecting to LDAP", e);
-		}
-
-		return null;
-	}
-
 	/**
 	 * Credentials are defined in the LDAP server and can not be manipulated
 	 * from Gitblit.
@@ -290,10 +229,7 @@
     public boolean supportsRoleChanges(UserModel user, Role role) {
     	if (Role.ADMIN == role) {
     		if (!supportsTeamMembershipChanges()) {
-    			List<String> admins = settings.getStrings(Keys.realm.ldap.admins);
-    			if (admins.contains(user.username)) {
-    				return false;
-    			}
+				return false;
     		}
     	}
         return true;
@@ -303,10 +239,7 @@
 	public boolean supportsRoleChanges(TeamModel team, Role role) {
 		if (Role.ADMIN == role) {
     		if (!supportsTeamMembershipChanges()) {
-    			List<String> admins = settings.getStrings(Keys.realm.ldap.admins);
-    			if (admins.contains("@" + team.name)) {
-    				return false;
-    			}
+				return false;
     		}
     	}
 		return true;
@@ -321,34 +254,33 @@
 	public UserModel authenticate(String username, char[] password) {
 		String simpleUsername = getSimpleUsername(username);
 
-		LDAPConnection ldapConnection = getLdapConnection();
-		if (ldapConnection != null) {
+		LdapConnection ldapConnection = new LdapConnection(settings);
+		if (ldapConnection.connect()) {
+
+			// Try to bind either to the "manager" account,
+			// or directly to the DN of the user logging in, if realm.ldap.bindpattern is configured.
+			String passwd = new String(password);
+			BindResult bindResult = null;
+			String bindPattern = settings.getString(Keys.realm.ldap.bindpattern, "");
+			if (! StringUtils.isEmpty(bindPattern)) {
+				bindResult = ldapConnection.bind(bindPattern, simpleUsername, passwd);
+			} else {
+				bindResult = ldapConnection.bind();
+			}
+			if (bindResult == null) {
+				ldapConnection.close();
+				return null;
+			}
+
+
 			try {
-				boolean alreadyAuthenticated = false;
-
-				String bindPattern = settings.getString(Keys.realm.ldap.bindpattern, "");
-				if (!StringUtils.isEmpty(bindPattern)) {
-					try {
-						String bindUser = StringUtils.replace(bindPattern, "${username}", escapeLDAPSearchFilter(simpleUsername));
-						ldapConnection.bind(bindUser, new String(password));
-
-						alreadyAuthenticated = true;
-					} catch (LDAPException e) {
-						return null;
-					}
-				}
-
 				// Find the logging in user's DN
-				String accountBase = settings.getString(Keys.realm.ldap.accountBase, "");
-				String accountPattern = settings.getString(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))");
-				accountPattern = StringUtils.replace(accountPattern, "${username}", escapeLDAPSearchFilter(simpleUsername));
-
-				SearchResult result = doSearch(ldapConnection, accountBase, accountPattern);
+				SearchResult result = ldapConnection.searchUser(simpleUsername);
 				if (result != null && result.getEntryCount() == 1) {
 					SearchResultEntry loggingInUser = result.getSearchEntries().get(0);
 					String loggingInUserDN = loggingInUser.getDN();
 
-					if (alreadyAuthenticated || isAuthenticated(ldapConnection, loggingInUserDN, new String(password))) {
+					if (ldapConnection.isAuthenticated(loggingInUserDN, passwd)) {
 						logger.debug("LDAP authenticated: " + username);
 
 						UserModel user = null;
@@ -360,7 +292,7 @@
 							}
 
 							// create a user cookie
-							setCookie(user, password);
+							setCookie(user);
 
 							if (!supportsTeamMembershipChanges()) {
 								getTeamsFromLdap(ldapConnection, simpleUsername, loggingInUser, user);
@@ -374,6 +306,8 @@
 
 							if (!supportsTeamMembershipChanges()) {
 								for (TeamModel userTeam : user.teams) {
+									// Is this an administrative team?
+									setAdminAttribute(userTeam);
 									updateTeam(userTeam);
 								}
 							}
@@ -404,10 +338,7 @@
 			if (!ArrayUtils.isEmpty(admins)) {
 				user.canAdmin = false;
 				for (String admin : admins) {
-					if (admin.startsWith("@") && user.isTeamMember(admin.substring(1))) {
-						// admin team
-						user.canAdmin = true;
-					} else if (user.getName().equalsIgnoreCase(admin)) {
+					if (user.getName().equalsIgnoreCase(admin)) {
 						// admin user
 						user.canAdmin = true;
 					}
@@ -416,6 +347,30 @@
 		}
 	}
 
+	/**
+	 * Set the canAdmin attribute for team retrieved from LDAP.
+	 * If we are not storing teams in LDAP and/or we have not defined any
+	 * administrator teams, then do not change the admin flag.
+	 *
+	 * @param team
+	 */
+	private void setAdminAttribute(TeamModel team) {
+		if (!supportsTeamMembershipChanges()) {
+			List<String> admins = settings.getStrings(Keys.realm.ldap.admins);
+			// if we have defined administrative teams, then set admin flag
+			// otherwise leave admin flag unchanged
+			if (!ArrayUtils.isEmpty(admins)) {
+				team.canAdmin = false;
+				for (String admin : admins) {
+					if (admin.startsWith("@") && team.name.equalsIgnoreCase(admin.substring(1))) {
+						// admin team
+						team.canAdmin = true;
+					}
+				}
+			}
+		}
+	}
+
 	private void setUserAttributes(UserModel user, SearchResultEntry userEntry) {
 		// Is this user an admin?
 		setAdminAttribute(user);
@@ -462,7 +417,7 @@
 		}
 	}
 
-	private void getTeamsFromLdap(LDAPConnection ldapConnection, String simpleUsername, SearchResultEntry loggingInUser, UserModel user) {
+	private void getTeamsFromLdap(LdapConnection ldapConnection, String simpleUsername, SearchResultEntry loggingInUser, UserModel user) {
 		String loggingInUserDN = loggingInUser.getDN();
 
 		// Clear the users team memberships - we're going to get them from LDAP
@@ -471,15 +426,15 @@
 		String groupBase = settings.getString(Keys.realm.ldap.groupBase, "");
 		String groupMemberPattern = settings.getString(Keys.realm.ldap.groupMemberPattern, "(&(objectClass=group)(member=${dn}))");
 
-		groupMemberPattern = StringUtils.replace(groupMemberPattern, "${dn}", escapeLDAPSearchFilter(loggingInUserDN));
-		groupMemberPattern = StringUtils.replace(groupMemberPattern, "${username}", escapeLDAPSearchFilter(simpleUsername));
+		groupMemberPattern = StringUtils.replace(groupMemberPattern, "${dn}", LdapConnection.escapeLDAPSearchFilter(loggingInUserDN));
+		groupMemberPattern = StringUtils.replace(groupMemberPattern, "${username}", LdapConnection.escapeLDAPSearchFilter(simpleUsername));
 
 		// Fill in attributes into groupMemberPattern
 		for (Attribute userAttribute : loggingInUser.getAttributes()) {
-			groupMemberPattern = StringUtils.replace(groupMemberPattern, "${" + userAttribute.getName() + "}", escapeLDAPSearchFilter(userAttribute.getValue()));
+			groupMemberPattern = StringUtils.replace(groupMemberPattern, "${" + userAttribute.getName() + "}", LdapConnection.escapeLDAPSearchFilter(userAttribute.getValue()));
 		}
 
-		SearchResult teamMembershipResult = doSearch(ldapConnection, groupBase, true, groupMemberPattern, Arrays.asList("cn"));
+		SearchResult teamMembershipResult = searchTeamsInLdap(ldapConnection, groupBase, true, groupMemberPattern, Arrays.asList("cn"));
 		if (teamMembershipResult != null && teamMembershipResult.getEntryCount() > 0) {
 			for (int i = 0; i < teamMembershipResult.getEntryCount(); i++) {
 				SearchResultEntry teamEntry = teamMembershipResult.getSearchEntries().get(i);
@@ -496,12 +451,12 @@
 		}
 	}
 
-	private void getEmptyTeamsFromLdap(LDAPConnection ldapConnection) {
+	private void getEmptyTeamsFromLdap(LdapConnection ldapConnection) {
 		logger.info("Start fetching empty teams from ldap.");
 		String groupBase = settings.getString(Keys.realm.ldap.groupBase, "");
 		String groupMemberPattern = settings.getString(Keys.realm.ldap.groupEmptyMemberPattern, "(&(objectClass=group)(!(member=*)))");
 
-		SearchResult teamMembershipResult = doSearch(ldapConnection, groupBase, true, groupMemberPattern, null);
+		SearchResult teamMembershipResult = searchTeamsInLdap(ldapConnection, groupBase, true, groupMemberPattern, null);
 		if (teamMembershipResult != null && teamMembershipResult.getEntryCount() > 0) {
 			for (int i = 0; i < teamMembershipResult.getEntryCount(); i++) {
 				SearchResultEntry teamEntry = teamMembershipResult.getSearchEntries().get(i);
@@ -511,6 +466,7 @@
 					TeamModel teamModel = userManager.getTeamModel(teamName);
 					if (teamModel == null) {
 						teamModel = createTeamFromLdap(teamEntry);
+						setAdminAttribute(teamModel);
 						userManager.updateTeamModel(teamModel);
 					}
 				}
@@ -519,6 +475,30 @@
 		logger.info("Finished fetching empty teams from ldap.");
 	}
 
+
+	private SearchResult searchTeamsInLdap(LdapConnection ldapConnection, String base, boolean dereferenceAliases, String filter, List<String> attributes) {
+		SearchResult result = ldapConnection.search(base, dereferenceAliases, filter, attributes);
+		if (result == null) {
+			return null;
+		}
+
+		if (result.getResultCode() != ResultCode.SUCCESS) {
+			// Retry the search with user authorization in case we searched as a manager account that could not search for teams.
+			logger.debug("Rebinding as user to search for teams in LDAP");
+			result = null;
+			if (ldapConnection.rebindAsUser()) {
+				result = ldapConnection.search(base, dereferenceAliases, filter, attributes);
+				if (result.getResultCode() != ResultCode.SUCCESS) {
+					return null;
+				}
+				logger.info("Successful search after rebinding as user.");
+			}
+		}
+
+		return result;
+	}
+
+
 	private TeamModel createTeamFromLdap(SearchResultEntry teamEntry) {
 		TeamModel answer = new TeamModel(teamEntry.getAttributeValue("cn"));
 		answer.accountType = getAccountType();
@@ -527,47 +507,22 @@
 		return answer;
 	}
 
-	private SearchResult doSearch(LDAPConnection ldapConnection, String base, String filter) {
-		try {
-			return ldapConnection.search(base, SearchScope.SUB, filter);
-		} catch (LDAPSearchException e) {
-			logger.error("Problem Searching LDAP", e);
-
-			return null;
-		}
-	}
-
-	private SearchResult doSearch(LDAPConnection ldapConnection, String base, boolean dereferenceAliases, String filter, List<String> attributes) {
+	private SearchResult doSearch(LdapConnection ldapConnection, String base, String filter) {
 		try {
 			SearchRequest searchRequest = new SearchRequest(base, SearchScope.SUB, filter);
-			if (dereferenceAliases) {
-				searchRequest.setDerefPolicy(DereferencePolicy.SEARCHING);
+			SearchResult result = ldapConnection.search(searchRequest);
+			if (result.getResultCode() != ResultCode.SUCCESS) {
+				return null;
 			}
-			if (attributes != null) {
-				searchRequest.setAttributes(attributes);
-			}
-			return ldapConnection.search(searchRequest);
-
-		} catch (LDAPSearchException e) {
-			logger.error("Problem Searching LDAP", e);
-
-			return null;
+			return result;
 		} catch (LDAPException e) {
 			logger.error("Problem creating LDAP search", e);
 			return null;
 		}
 	}
 
-	private boolean isAuthenticated(LDAPConnection ldapConnection, String userDn, String password) {
-		try {
-			// Binding will stop any LDAP-Injection Attacks since the searched-for user needs to bind to that DN
-			ldapConnection.bind(userDn, password);
-			return true;
-		} catch (LDAPException e) {
-			logger.error("Error authenticating user", e);
-			return false;
-		}
-	}
+
+
 
 	/**
 	 * Returns a simple username without any domain prefixes.
@@ -584,34 +539,6 @@
 		return username;
 	}
 
-	// From: https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java
-	public static final String escapeLDAPSearchFilter(String filter) {
-		StringBuilder sb = new StringBuilder();
-		for (int i = 0; i < filter.length(); i++) {
-			char curChar = filter.charAt(i);
-			switch (curChar) {
-			case '\\':
-				sb.append("\\5c");
-				break;
-			case '*':
-				sb.append("\\2a");
-				break;
-			case '(':
-				sb.append("\\28");
-				break;
-			case ')':
-				sb.append("\\29");
-				break;
-			case '\u0000':
-				sb.append("\\00");
-				break;
-			default:
-				sb.append(curChar);
-			}
-		}
-		return sb.toString();
-	}
-
 	private void configureSyncService() {
 		LdapSyncService ldapSyncService = new LdapSyncService(settings, this);
 		if (ldapSyncService.isReady()) {
@@ -624,5 +551,4 @@
 			logger.info("Ldap sync service is disabled.");
 		}
 	}
-
 }
diff --git a/src/main/java/com/gitblit/auth/PAMAuthProvider.java b/src/main/java/com/gitblit/auth/PAMAuthProvider.java
index 46f4dd6..b38d49d 100644
--- a/src/main/java/com/gitblit/auth/PAMAuthProvider.java
+++ b/src/main/java/com/gitblit/auth/PAMAuthProvider.java
@@ -122,7 +122,7 @@
         }
 
         // create a user cookie
-        setCookie(user, password);
+        setCookie(user);
 
         // update user attributes from UnixUser
         user.accountType = getAccountType();
diff --git a/src/main/java/com/gitblit/auth/RedmineAuthProvider.java b/src/main/java/com/gitblit/auth/RedmineAuthProvider.java
index 27cece2..364aff0 100644
--- a/src/main/java/com/gitblit/auth/RedmineAuthProvider.java
+++ b/src/main/java/com/gitblit/auth/RedmineAuthProvider.java
@@ -139,7 +139,7 @@
         }
 
         // create a user cookie
-        setCookie(user, password);
+        setCookie(user);
 
         // update user attributes from Redmine
         user.accountType = getAccountType();
diff --git a/src/main/java/com/gitblit/auth/SalesforceAuthProvider.java b/src/main/java/com/gitblit/auth/SalesforceAuthProvider.java
index df033c2..79c3a0c 100644
--- a/src/main/java/com/gitblit/auth/SalesforceAuthProvider.java
+++ b/src/main/java/com/gitblit/auth/SalesforceAuthProvider.java
@@ -66,7 +66,7 @@
 					user = new UserModel(simpleUsername);
 				}
 
-				setCookie(user, password);
+				setCookie(user);
 				setUserAttributes(user, info);
 
 				updateUser(user);
diff --git a/src/main/java/com/gitblit/auth/WindowsAuthProvider.java b/src/main/java/com/gitblit/auth/WindowsAuthProvider.java
index aee5100..4c31fb1 100644
--- a/src/main/java/com/gitblit/auth/WindowsAuthProvider.java
+++ b/src/main/java/com/gitblit/auth/WindowsAuthProvider.java
@@ -153,7 +153,7 @@
         }
 
         // create a user cookie
-        setCookie(user, password);
+        setCookie(user);
 
         // update user attributes from Windows identity
         user.accountType = getAccountType();
diff --git a/src/main/java/com/gitblit/client/EditUserDialog.java b/src/main/java/com/gitblit/client/EditUserDialog.java
index 676916b..4b01ff0 100644
--- a/src/main/java/com/gitblit/client/EditUserDialog.java
+++ b/src/main/java/com/gitblit/client/EditUserDialog.java
@@ -330,7 +330,7 @@
 			}

 

 			// change the cookie

-			user.cookie = StringUtils.getSHA1(user.username + password);

+			user.cookie = user.createCookie();

 

 			String type = settings.get(Keys.realm.passwordStorage).getString("md5");

 			if (type.equalsIgnoreCase("md5")) {

diff --git a/src/main/java/com/gitblit/git/PatchsetReceivePack.java b/src/main/java/com/gitblit/git/PatchsetReceivePack.java
index 33fa470..4a09139 100644
--- a/src/main/java/com/gitblit/git/PatchsetReceivePack.java
+++ b/src/main/java/com/gitblit/git/PatchsetReceivePack.java
@@ -599,7 +599,7 @@
 		}

 

 		// ensure that the patchset can be cleanly merged right now

-		MergeStatus status = JGitUtils.canMerge(getRepository(), tipCommit.getName(), forBranch);

+		MergeStatus status = JGitUtils.canMerge(getRepository(), tipCommit.getName(), forBranch, repository.mergeType);

 		switch (status) {

 		case ALREADY_MERGED:

 			sendError("");

@@ -1279,6 +1279,7 @@
 				getRepository(),

 				patchset.tip,

 				ticket.mergeTo,

+				getRepositoryModel().mergeType,

 				committer,

 				message);

 

diff --git a/src/main/java/com/gitblit/ldap/LdapConnection.java b/src/main/java/com/gitblit/ldap/LdapConnection.java
new file mode 100644
index 0000000..14fedf1
--- /dev/null
+++ b/src/main/java/com/gitblit/ldap/LdapConnection.java
@@ -0,0 +1,338 @@
+/*
+ * Copyright 2016 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.ldap;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.IStoredSettings;
+import com.gitblit.Keys;
+import com.gitblit.utils.StringUtils;
+import com.unboundid.ldap.sdk.BindRequest;
+import com.unboundid.ldap.sdk.BindResult;
+import com.unboundid.ldap.sdk.DereferencePolicy;
+import com.unboundid.ldap.sdk.ExtendedResult;
+import com.unboundid.ldap.sdk.LDAPConnection;
+import com.unboundid.ldap.sdk.LDAPException;
+import com.unboundid.ldap.sdk.LDAPSearchException;
+import com.unboundid.ldap.sdk.ResultCode;
+import com.unboundid.ldap.sdk.SearchRequest;
+import com.unboundid.ldap.sdk.SearchResult;
+import com.unboundid.ldap.sdk.SearchScope;
+import com.unboundid.ldap.sdk.SimpleBindRequest;
+import com.unboundid.ldap.sdk.extensions.StartTLSExtendedRequest;
+import com.unboundid.util.ssl.SSLUtil;
+import com.unboundid.util.ssl.TrustAllTrustManager;
+
+public class LdapConnection implements AutoCloseable {
+
+	private final Logger logger = LoggerFactory.getLogger(getClass());
+
+	private IStoredSettings settings;
+
+	private LDAPConnection conn;
+	private SimpleBindRequest currentBindRequest;
+	private SimpleBindRequest managerBindRequest;
+	private SimpleBindRequest userBindRequest;
+
+
+	// From: https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java
+	public static final String escapeLDAPSearchFilter(String filter) {
+		StringBuilder sb = new StringBuilder();
+		for (int i = 0; i < filter.length(); i++) {
+			char curChar = filter.charAt(i);
+			switch (curChar) {
+			case '\\':
+				sb.append("\\5c");
+				break;
+			case '*':
+				sb.append("\\2a");
+				break;
+			case '(':
+				sb.append("\\28");
+				break;
+			case ')':
+				sb.append("\\29");
+				break;
+			case '\u0000':
+				sb.append("\\00");
+				break;
+			default:
+				sb.append(curChar);
+			}
+		}
+		return sb.toString();
+	}
+
+
+
+	public static String getAccountBase(IStoredSettings settings) {
+		return settings.getString(Keys.realm.ldap.accountBase, "");
+	}
+
+	public static String getAccountPattern(IStoredSettings settings) {
+		return settings.getString(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))");
+	}
+
+
+
+	public LdapConnection(IStoredSettings settings) {
+		this.settings = settings;
+
+		String bindUserName = settings.getString(Keys.realm.ldap.username, "");
+		String bindPassword = settings.getString(Keys.realm.ldap.password, "");
+		if (StringUtils.isEmpty(bindUserName) && StringUtils.isEmpty(bindPassword)) {
+			this.managerBindRequest = new SimpleBindRequest();
+		}
+		this.managerBindRequest = new SimpleBindRequest(bindUserName, bindPassword);
+	}
+
+
+
+	public String getAccountBase() {
+		return getAccountBase(settings);
+	}
+
+	public String getAccountPattern() {
+		return getAccountPattern(settings);
+	}
+
+
+
+	public boolean connect() {
+		try {
+			URI ldapUrl = new URI(settings.getRequiredString(Keys.realm.ldap.server));
+			String ldapHost = ldapUrl.getHost();
+			int ldapPort = ldapUrl.getPort();
+
+			if (ldapUrl.getScheme().equalsIgnoreCase("ldaps")) {
+				// SSL
+				SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager());
+				conn = new LDAPConnection(sslUtil.createSSLSocketFactory());
+				if (ldapPort == -1) {
+					ldapPort = 636;
+				}
+			} else if (ldapUrl.getScheme().equalsIgnoreCase("ldap") || ldapUrl.getScheme().equalsIgnoreCase("ldap+tls")) {
+				// no encryption or StartTLS
+				conn = new LDAPConnection();
+				 if (ldapPort == -1) {
+					 ldapPort = 389;
+				 }
+			} else {
+				logger.error("Unsupported LDAP URL scheme: " + ldapUrl.getScheme());
+				return false;
+			}
+
+			conn.connect(ldapHost, ldapPort);
+
+			if (ldapUrl.getScheme().equalsIgnoreCase("ldap+tls")) {
+				SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager());
+				ExtendedResult extendedResult = conn.processExtendedOperation(
+						new StartTLSExtendedRequest(sslUtil.createSSLContext()));
+				if (extendedResult.getResultCode() != ResultCode.SUCCESS) {
+					throw new LDAPException(extendedResult.getResultCode());
+				}
+			}
+
+			return true;
+
+		} catch (URISyntaxException e) {
+			logger.error("Bad LDAP URL, should be in the form: ldap(s|+tls)://<server>:<port>", e);
+		} catch (GeneralSecurityException e) {
+			logger.error("Unable to create SSL Connection", e);
+		} catch (LDAPException e) {
+			logger.error("Error Connecting to LDAP", e);
+		}
+
+		return false;
+	}
+
+
+	public void close() {
+		if (conn != null) {
+			conn.close();
+		}
+	}
+
+
+
+	/**
+	 * Bind using the manager credentials set in realm.ldap.username and ..password
+	 * @return A bind result, or null if binding failed.
+	 */
+	public BindResult bind() {
+		BindResult result = null;
+		try {
+			result = conn.bind(managerBindRequest);
+			currentBindRequest = managerBindRequest;
+		} catch (LDAPException e) {
+			logger.error("Error authenticating to LDAP with manager account to search the directory.");
+			logger.error("  Please check your settings for realm.ldap.username and realm.ldap.password.");
+			logger.debug("  Received exception when binding to LDAP", e);
+			return null;
+		}
+		return result;
+	}
+
+
+	/**
+	 * Bind using the given credentials, by filling in the username in the given {@code bindPattern} to
+	 * create the DN.
+	 * @return A bind result, or null if binding failed.
+	 */
+	public BindResult bind(String bindPattern, String simpleUsername, String password) {
+		BindResult result = null;
+		try {
+			String bindUser = StringUtils.replace(bindPattern, "${username}", escapeLDAPSearchFilter(simpleUsername));
+			SimpleBindRequest request = new SimpleBindRequest(bindUser, password);
+			result = conn.bind(request);
+			userBindRequest = request;
+			currentBindRequest = userBindRequest;
+		} catch (LDAPException e) {
+			logger.error("Error authenticating to LDAP with user account to search the directory.");
+			logger.error("  Please check your settings for realm.ldap.bindpattern.");
+			logger.debug("  Received exception when binding to LDAP", e);
+			return null;
+		}
+		return result;
+	}
+
+
+	public boolean rebindAsUser() {
+		if (userBindRequest == null || currentBindRequest == userBindRequest) {
+			return false;
+		}
+		try {
+			conn.bind(userBindRequest);
+			currentBindRequest = userBindRequest;
+		} catch (LDAPException e) {
+			conn.close();
+			logger.error("Error rebinding to LDAP with user account.", e);
+			return false;
+		}
+		return true;
+	}
+
+
+
+	public boolean isAuthenticated(String userDn, String password) {
+		verifyCurrentBinding();
+
+		// If the currently bound DN is already the DN of the logging in user, authentication has already happened
+		// during the previous bind operation. We accept this and return with the current bind left in place.
+		// This could also be changed to always retry binding as the logging in user, to make sure that the
+		// connection binding has not been tampered with in between. So far I see no way how this could happen
+		// and thus skip the repeated binding.
+		// This check also makes sure that the DN in realm.ldap.bindpattern actually matches the DN that was found
+		// when searching the user entry.
+		String boundDN = currentBindRequest.getBindDN();
+		if (boundDN != null && boundDN.equals(userDn)) {
+			return true;
+		}
+
+		// Bind a the logging in user to check for authentication.
+		// Afterwards, bind as the original bound DN again, to restore the previous authorization.
+		boolean isAuthenticated = false;
+		try {
+			// Binding will stop any LDAP-Injection Attacks since the searched-for user needs to bind to that DN
+			SimpleBindRequest ubr = new SimpleBindRequest(userDn, password);
+			conn.bind(ubr);
+			isAuthenticated = true;
+			userBindRequest = ubr;
+		} catch (LDAPException e) {
+			logger.error("Error authenticating user ({})", userDn, e);
+		}
+
+		try {
+			conn.bind(currentBindRequest);
+		} catch (LDAPException e) {
+			logger.error("Error reinstating original LDAP authorization (code {}). Team information may be inaccurate for this log in.",
+						e.getResultCode(), e);
+		}
+		return isAuthenticated;
+	}
+
+
+
+
+	public SearchResult search(SearchRequest request) {
+		try {
+			return conn.search(request);
+		} catch (LDAPSearchException e) {
+			logger.error("Problem Searching LDAP [{}]",  e.getResultCode());
+			return e.getSearchResult();
+		}
+	}
+
+
+	public SearchResult search(String base, boolean dereferenceAliases, String filter, List<String> attributes) {
+		try {
+			SearchRequest searchRequest = new SearchRequest(base, SearchScope.SUB, filter);
+			if (dereferenceAliases) {
+				searchRequest.setDerefPolicy(DereferencePolicy.SEARCHING);
+			}
+			if (attributes != null) {
+				searchRequest.setAttributes(attributes);
+			}
+			SearchResult result = search(searchRequest);
+			return result;
+
+		} catch (LDAPException e) {
+			logger.error("Problem creating LDAP search", e);
+			return null;
+		}
+	}
+
+
+	public SearchResult searchUser(String username, List<String> attributes) {
+
+		String accountPattern = getAccountPattern();
+		accountPattern = StringUtils.replace(accountPattern, "${username}", escapeLDAPSearchFilter(username));
+
+		return search(getAccountBase(), false, accountPattern, attributes);
+	}
+
+
+	public SearchResult searchUser(String username) {
+		return searchUser(username, null);
+	}
+
+
+
+	private boolean verifyCurrentBinding() {
+		BindRequest lastBind = conn.getLastBindRequest();
+		if (lastBind == currentBindRequest) {
+			return true;
+		}
+		logger.debug("Unexpected binding in LdapConnection. {} != {}", lastBind, currentBindRequest);
+
+		String lastBoundDN = ((SimpleBindRequest)lastBind).getBindDN();
+		String boundDN = currentBindRequest.getBindDN();
+		logger.debug("Currently bound as '{}', check authentication for '{}'", lastBoundDN, boundDN);
+		if (boundDN != null && ! boundDN.equals(lastBoundDN)) {
+			logger.warn("Unexpected binding DN in LdapConnection. '{}' != '{}'.", lastBoundDN, boundDN);
+			logger.warn("Updated binding information in LDAP connection.");
+			currentBindRequest = (SimpleBindRequest)lastBind;
+			return false;
+		}
+		return true;
+	}
+}
diff --git a/src/main/java/com/gitblit/manager/AuthenticationManager.java b/src/main/java/com/gitblit/manager/AuthenticationManager.java
index 4978763..0a4d8ed 100644
--- a/src/main/java/com/gitblit/manager/AuthenticationManager.java
+++ b/src/main/java/com/gitblit/manager/AuthenticationManager.java
@@ -608,6 +608,11 @@
 						userCookie = new Cookie(Constants.NAME, cookie);
 						// expire the cookie in 7 days
 						userCookie.setMaxAge((int) TimeUnit.DAYS.toSeconds(7));
+
+						// Set cookies HttpOnly so they are not accessible to JavaScript engines
+						userCookie.setHttpOnly(true);
+						// Set secure cookie if only HTTPS is used
+						userCookie.setSecure(httpsOnly());
 					}
 				}
 				String path = "/";
@@ -622,6 +627,15 @@
 		}
 	}
 
+
+	private boolean httpsOnly() {
+		int port = settings.getInteger(Keys.server.httpPort, 0);
+		int tlsPort = settings.getInteger(Keys.server.httpsPort, 0);
+		return  (port <= 0 && tlsPort > 0) ||
+				(port > 0 && tlsPort > 0 && settings.getBoolean(Keys.server.redirectToHttpsPort, true) );
+	}
+
+
 	/**
 	 * Logout a user.
 	 *
diff --git a/src/main/java/com/gitblit/manager/RepositoryManager.java b/src/main/java/com/gitblit/manager/RepositoryManager.java
index e9bf5b8..2be6587 100644
--- a/src/main/java/com/gitblit/manager/RepositoryManager.java
+++ b/src/main/java/com/gitblit/manager/RepositoryManager.java
@@ -63,6 +63,7 @@
 import com.gitblit.Constants.AuthorizationControl;
 import com.gitblit.Constants.CommitMessageRenderer;
 import com.gitblit.Constants.FederationStrategy;
+import com.gitblit.Constants.MergeType;
 import com.gitblit.Constants.PermissionType;
 import com.gitblit.Constants.RegistrantType;
 import com.gitblit.GitBlitException;
@@ -899,6 +900,7 @@
 			model.acceptNewTickets = getConfig(config, "acceptNewTickets", true);
 			model.requireApproval = getConfig(config, "requireApproval", settings.getBoolean(Keys.tickets.requireApproval, false));
 			model.mergeTo = getConfig(config, "mergeTo", null);
+			model.mergeType = MergeType.fromName(getConfig(config, "mergeType", settings.getString(Keys.tickets.mergeType, null)));
 			model.useIncrementalPushTags = getConfig(config, "useIncrementalPushTags", false);
 			model.incrementalPushTagPrefix = getConfig(config, "incrementalPushTagPrefix", null);
 			model.allowForks = getConfig(config, "allowForks", true);
@@ -1557,6 +1559,13 @@
 		if (!StringUtils.isEmpty(repository.mergeTo)) {
 			config.setString(Constants.CONFIG_GITBLIT, null, "mergeTo", repository.mergeTo);
 		}
+		if (repository.mergeType == null || repository.mergeType == MergeType.fromName(settings.getString(Keys.tickets.mergeType, null))) {
+			// use default
+			config.unset(Constants.CONFIG_GITBLIT, null, "mergeType");
+		} else {
+			// override default
+			config.setString(Constants.CONFIG_GITBLIT, null, "mergeType", repository.mergeType.name());
+		}
 		config.setBoolean(Constants.CONFIG_GITBLIT, null, "useIncrementalPushTags", repository.useIncrementalPushTags);
 		if (StringUtils.isEmpty(repository.incrementalPushTagPrefix) ||
 				repository.incrementalPushTagPrefix.equals(settings.getString(Keys.git.defaultIncrementalPushTagPrefix, "r"))) {
@@ -1952,39 +1961,47 @@
 	}
 
 	protected void configureCommitCache() {
-		int daysToCache = settings.getInteger(Keys.web.activityCacheDays, 14);
+		final int daysToCache = settings.getInteger(Keys.web.activityCacheDays, 14);
 		if (daysToCache <= 0) {
 			logger.info("Commit cache is disabled");
-		} else {
-			long start = System.nanoTime();
-			long repoCount = 0;
-			long commitCount = 0;
-			logger.info(MessageFormat.format("Preparing {0} day commit cache. please wait...", daysToCache));
-			CommitCache.instance().setCacheDays(daysToCache);
-			Date cutoff = CommitCache.instance().getCutoffDate();
-			for (String repositoryName : getRepositoryList()) {
-				RepositoryModel model = getRepositoryModel(repositoryName);
-				if (model != null && model.hasCommits && model.lastChange.after(cutoff)) {
-					repoCount++;
-					Repository repository = getRepository(repositoryName);
-					for (RefModel ref : JGitUtils.getLocalBranches(repository, true, -1)) {
-						if (!ref.getDate().after(cutoff)) {
-							// branch not recently updated
-							continue;
-						}
-						List<?> commits = CommitCache.instance().getCommits(repositoryName, repository, ref.getName());
-						if (commits.size() > 0) {
-							logger.info(MessageFormat.format("  cached {0} commits for {1}:{2}",
-									commits.size(), repositoryName, ref.getName()));
-							commitCount += commits.size();
-						}
-					}
-					repository.close();
-				}
-			}
-			logger.info(MessageFormat.format("built {0} day commit cache of {1} commits across {2} repositories in {3} msecs",
-					daysToCache, commitCount, repoCount, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)));
+			return;
 		}
+		logger.info(MessageFormat.format("Preparing {0} day commit cache...", daysToCache));
+		CommitCache.instance().setCacheDays(daysToCache);
+		Thread loader = new Thread() {
+			@Override
+			public void run() {
+				long start = System.nanoTime();
+				long repoCount = 0;
+				long commitCount = 0;
+				Date cutoff = CommitCache.instance().getCutoffDate();
+				for (String repositoryName : getRepositoryList()) {
+					RepositoryModel model = getRepositoryModel(repositoryName);
+					if (model != null && model.hasCommits && model.lastChange.after(cutoff)) {
+						repoCount++;
+						Repository repository = getRepository(repositoryName);
+						for (RefModel ref : JGitUtils.getLocalBranches(repository, true, -1)) {
+							if (!ref.getDate().after(cutoff)) {
+								// branch not recently updated
+								continue;
+							}
+							List<?> commits = CommitCache.instance().getCommits(repositoryName, repository, ref.getName());
+							if (commits.size() > 0) {
+								logger.info(MessageFormat.format("  cached {0} commits for {1}:{2}",
+										commits.size(), repositoryName, ref.getName()));
+								commitCount += commits.size();
+							}
+						}
+						repository.close();
+					}
+				}
+				logger.info(MessageFormat.format("built {0} day commit cache of {1} commits across {2} repositories in {3} msecs",
+						daysToCache, commitCount, repoCount, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)));
+			}
+		};
+		loader.setName("CommitCacheLoader");
+		loader.setDaemon(true);
+		loader.start();
 	}
 
 	protected void confirmWriteAccess() {
diff --git a/src/main/java/com/gitblit/models/RepositoryModel.java b/src/main/java/com/gitblit/models/RepositoryModel.java
index a81c622..67ee1c7 100644
--- a/src/main/java/com/gitblit/models/RepositoryModel.java
+++ b/src/main/java/com/gitblit/models/RepositoryModel.java
@@ -28,6 +28,7 @@
 import com.gitblit.Constants.AuthorizationControl;

 import com.gitblit.Constants.CommitMessageRenderer;

 import com.gitblit.Constants.FederationStrategy;

+import com.gitblit.Constants.MergeType;

 import com.gitblit.utils.ArrayUtils;

 import com.gitblit.utils.ModelUtils;

 import com.gitblit.utils.StringUtils;

@@ -89,6 +90,7 @@
 	public boolean acceptNewTickets;

 	public boolean requireApproval;
 	public String mergeTo;

+	public MergeType mergeType;

 

 	public transient boolean isCollectingGarbage;

 	public Date lastGC;

@@ -111,6 +113,7 @@
 		this.isBare = true;

 		this.acceptNewTickets = true;

 		this.acceptNewPatchsets = true;

+		this.mergeType = MergeType.DEFAULT_MERGE_TYPE;

 

 		addOwner(owner);

 	}

diff --git a/src/main/java/com/gitblit/models/TicketModel.java b/src/main/java/com/gitblit/models/TicketModel.java
index d534589..65e29dc 100644
--- a/src/main/java/com/gitblit/models/TicketModel.java
+++ b/src/main/java/com/gitblit/models/TicketModel.java
@@ -43,6 +43,8 @@
 
 import org.eclipse.jgit.util.RelativeDateFormatter;
 
+import com.gitblit.Constants;
+
 /**
  * The Gitblit Ticket model, its component classes, and enums.
  *
@@ -773,10 +775,10 @@
 			}
 			
 			try {
-				Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
+				Pattern mentions = Pattern.compile(Constants.REGEX_TICKET_MENTION);
 				Matcher m = mentions.matcher(text);
 				while (m.find()) {
-					String username = m.group(1);
+					String username = m.group("user");
 					plusList(Field.mentions, username);
 				}
 			} catch (Exception e) {
diff --git a/src/main/java/com/gitblit/models/UserModel.java b/src/main/java/com/gitblit/models/UserModel.java
index e152274..1d9e413 100644
--- a/src/main/java/com/gitblit/models/UserModel.java
+++ b/src/main/java/com/gitblit/models/UserModel.java
@@ -36,6 +36,7 @@
 import com.gitblit.Constants.RegistrantType;

 import com.gitblit.utils.ArrayUtils;

 import com.gitblit.utils.ModelUtils;

+import com.gitblit.utils.SecureRandom;

 import com.gitblit.utils.StringUtils;

 

 /**

@@ -52,6 +53,8 @@
 

 	public static final UserModel ANONYMOUS = new UserModel();

 

+	private static final SecureRandom RANDOM = new SecureRandom();

+

 	// field names are reflectively mapped in EditUser page

 	public String username;

 	public String password;

@@ -660,4 +663,8 @@
 		String projectPath = StringUtils.getFirstPathElement(repository);

 		return !StringUtils.isEmpty(projectPath) && projectPath.equalsIgnoreCase(getPersonalPath());

 	}

+

+	public String createCookie() {

+		return StringUtils.getSHA1(RANDOM.randomBytes(32));

+	}

 }

diff --git a/src/main/java/com/gitblit/service/MailService.java b/src/main/java/com/gitblit/service/MailService.java
index ec3a84c..58acc9c 100644
--- a/src/main/java/com/gitblit/service/MailService.java
+++ b/src/main/java/com/gitblit/service/MailService.java
@@ -17,6 +17,7 @@
 

 import java.io.File;

 import java.util.ArrayList;

+import java.util.Arrays;

 import java.util.Date;

 import java.util.List;

 import java.util.Properties;

@@ -31,6 +32,7 @@
 import javax.mail.Message;

 import javax.mail.MessagingException;

 import javax.mail.PasswordAuthentication;

+import javax.mail.SendFailedException;

 import javax.mail.Session;

 import javax.mail.Transport;

 import javax.mail.internet.InternetAddress;

@@ -272,9 +274,22 @@
 				while ((message = queue.poll()) != null) {

 					try {

 						if (settings.getBoolean(Keys.mail.debug, false)) {

-							logger.info("send: " + StringUtils.trimString(message.getSubject(), 60));

+							logger.info("send: '" + StringUtils.trimString(message.getSubject(), 60)

+									    + "' to:" + StringUtils.trimString(Arrays.toString(message.getAllRecipients()), 300));

 						}

 						Transport.send(message);

+					} catch (SendFailedException sfe) {

+						if (settings.getBoolean(Keys.mail.debug, false)) {

+							logger.error("Failed to send message: {}", sfe.getMessage());

+							logger.info("   Invalid addresses: {}", Arrays.toString(sfe.getInvalidAddresses()));

+							logger.info("   Valid sent addresses: {}", Arrays.toString(sfe.getValidSentAddresses()));

+							logger.info("   Valid unset addresses: {}", Arrays.toString(sfe.getValidUnsentAddresses()));

+							logger.info("", sfe);

+						}

+						else {

+							logger.error("Failed to send message: {}", sfe.getMessage(), sfe.getNextException());

+						}

+						failures.add(message);

 					} catch (Throwable e) {

 						logger.error("Failed to send message", e);

 						failures.add(message);

diff --git a/src/main/java/com/gitblit/tickets/TicketNotifier.java b/src/main/java/com/gitblit/tickets/TicketNotifier.java
index 8c7fe6d..b913db2 100644
--- a/src/main/java/com/gitblit/tickets/TicketNotifier.java
+++ b/src/main/java/com/gitblit/tickets/TicketNotifier.java
@@ -573,10 +573,10 @@
 		// cc users mentioned in last comment
 		Change lastChange = ticket.changes.get(ticket.changes.size() - 1);
 		if (lastChange.hasComment()) {
-			Pattern p = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
+			Pattern p = Pattern.compile(Constants.REGEX_TICKET_MENTION);
 			Matcher m = p.matcher(lastChange.comment.text);
 			while (m.find()) {
-				String username = m.group();
+				String username = m.group("user");
 				ccs.add(username);
 			}
 		}
diff --git a/src/main/java/com/gitblit/transport/ssh/IPublicKeyManager.java b/src/main/java/com/gitblit/transport/ssh/IPublicKeyManager.java
index 1e74b2f..ffe64f5 100644
--- a/src/main/java/com/gitblit/transport/ssh/IPublicKeyManager.java
+++ b/src/main/java/com/gitblit/transport/ssh/IPublicKeyManager.java
@@ -25,6 +25,7 @@
 import org.slf4j.LoggerFactory;
 
 import com.gitblit.manager.IManager;
+import com.gitblit.models.UserModel;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.CacheLoader.InvalidCacheLoadException;
@@ -99,4 +100,16 @@
 	public abstract boolean removeKey(String username, SshKey key);
 
 	public abstract boolean removeAllKeys(String username);
+
+	public boolean supportsWritingKeys(UserModel user) {
+		return (user != null);
+	}
+
+	public boolean supportsCommentChanges(UserModel user) {
+		return (user != null);
+	}
+
+	public boolean supportsPermissionChanges(UserModel user) {
+		return (user != null);
+	}
 }
diff --git a/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java
new file mode 100644
index 0000000..c62c4de
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java
@@ -0,0 +1,435 @@
+/*
+ * Copyright 2016 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.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.server.config.keys.AuthorizedKeyEntry;
+
+import com.gitblit.IStoredSettings;
+import com.gitblit.Keys;
+import com.gitblit.Constants.AccessPermission;
+import com.gitblit.ldap.LdapConnection;
+import com.gitblit.models.UserModel;
+import com.gitblit.utils.StringUtils;
+import com.google.common.base.Joiner;
+import com.google.inject.Inject;
+import com.unboundid.ldap.sdk.BindResult;
+import com.unboundid.ldap.sdk.ResultCode;
+import com.unboundid.ldap.sdk.SearchResult;
+import com.unboundid.ldap.sdk.SearchResultEntry;
+
+/**
+ * LDAP-only public key manager
+ *
+ * Retrieves public keys from user's LDAP entries. Using this key manager,
+ * no SSH keys can be edited, i.e. added, removed, permissions changed, etc.
+ *
+ * This key manager supports SSH key entries in LDAP of the following form:
+ * [<prefix>:] [<options>] <type> <key> [<comment>]
+ * This follows the required form of entries in the authenticated_keys file,
+ * with an additional optional prefix. Key entries must have a key type
+ * (like "ssh-rsa") and a key, and may have a comment at the end.
+ *
+ * An entry may specify login options as specified for the authorized_keys file.
+ * The 'environment' option may be used to set the permissions for the key
+ * by setting a 'gbPerm' environment variable. The key manager will interpret
+ * such a environment variable option and use the set permission string to set
+ * the permission on the key in Gitblit. Example:
+ *   environment="gbPerm=V",pty ssh-rsa AAAxjka.....dv= Clone only key
+ * Above entry would create a RSA key with the comment "Clone only key" and
+ * set the key permission to CLONE. All other options are ignored.
+ *
+ * In Active Directory SSH public keys are sometimes stored in the attribute
+ * 'altSecurityIdentity'. The attribute value is usually prefixed by a type
+ * identifier. LDAP entries could have the following attribute values:
+ *   altSecurityIdentity: X.509: ADKEJBAKDBZUPABBD...
+ *   altSecurityIdentity: SshKey: ssh-dsa AAAAknenazuzucbhda...
+ * This key manager supports this by allowing an optional prefix to identify
+ * SSH keys. The prefix to be used should be set in the 'realm.ldap.sshPublicKey'
+ * setting by separating it from the attribute name with a colon, e.g.:
+ *    realm.ldap.sshPublicKey = altSecurityIdentity:SshKey
+ *
+ * @author Florian Zschocke
+ *
+ */
+public class LdapKeyManager extends IPublicKeyManager {
+
+	/**
+	 * Pattern to find prefixes like 'SSHKey:' in key entries.
+	 * These prefixes describe the type of an altSecurityIdentity.
+	 * The pattern accepts anything but quote and colon up to the
+	 * first colon at the start of a string.
+	 */
+	private static final Pattern PREFIX_PATTERN = Pattern.compile("^([^\":]+):");
+	/**
+	 * Pattern to find the string describing Gitblit permissions for a SSH key.
+	 * The pattern matches on a string starting with 'gbPerm', matched case-insensitive,
+	 * followed by '=' with optional whitespace around it, followed by a string of
+	 * upper and lower case letters and '+' and '-' for the permission, which can optionally
+	 * be enclosed in '"' or '\"' (only the leading quote is matched in the pattern).
+	 * Only the group describing the permission is a capturing group.
+	 */
+	private static final Pattern GB_PERM_PATTERN = Pattern.compile("(?i:gbPerm)\\s*=\\s*(?:\\\\\"|\")?\\s*([A-Za-z+-]+)");
+
+
+	private final IStoredSettings settings;
+
+
+
+	@Inject
+	public LdapKeyManager(IStoredSettings settings) {
+		this.settings = settings;
+	}
+
+
+	@Override
+	public String toString() {
+		return getClass().getSimpleName();
+	}
+
+	@Override
+	public LdapKeyManager start() {
+		log.info(toString());
+		return this;
+	}
+
+	@Override
+	public boolean isReady() {
+		return true;
+	}
+
+	@Override
+	public LdapKeyManager stop() {
+		return this;
+	}
+
+	@Override
+	protected boolean isStale(String username) {
+		// always return true so we gets keys from LDAP every time
+		return true;
+	}
+
+	@Override
+	protected List<SshKey> getKeysImpl(String username) {
+		try (LdapConnection conn = new LdapConnection(settings)) {
+			if (conn.connect()) {
+				log.info("loading ssh key for {} from LDAP directory", username);
+
+				BindResult bindResult = conn.bind();
+				if (bindResult == null) {
+					conn.close();
+					return null;
+				}
+
+				// Search the user entity
+
+				// Support prefixing the key data, e.g. when using altSecurityIdentities in AD.
+				String pubKeyAttribute = settings.getString(Keys.realm.ldap.sshPublicKey, "sshPublicKey");
+				String pkaPrefix = null;
+				int idx = pubKeyAttribute.indexOf(':');
+				if (idx > 0) {
+					pkaPrefix = pubKeyAttribute.substring(idx +1);
+					pubKeyAttribute = pubKeyAttribute.substring(0, idx);
+				}
+
+				SearchResult result = conn.searchUser(getSimpleUsername(username), Arrays.asList(pubKeyAttribute));
+				conn.close();
+
+				if (result != null && result.getResultCode() == ResultCode.SUCCESS) {
+					if ( result.getEntryCount() > 1) {
+						log.info("Found more than one entry for user {} in LDAP. Cannot retrieve SSH key.", username);
+						return null;
+					} else if ( result.getEntryCount() < 1) {
+						log.info("Found no entry for user {} in LDAP. Cannot retrieve SSH key.", username);
+						return null;
+					}
+
+					// Retrieve the SSH key attributes
+					SearchResultEntry foundUser = result.getSearchEntries().get(0);
+					String[] attrs = foundUser.getAttributeValues(pubKeyAttribute);
+					if (attrs == null ||attrs.length == 0) {
+						log.info("found no keys for user {} under attribute {} in directory", username, pubKeyAttribute);
+						return null;
+					}
+
+
+					// Filter resulting list to match with required special prefix in entry
+					List<GbAuthorizedKeyEntry> authorizedKeys = new ArrayList<>(attrs.length);
+					Matcher m = PREFIX_PATTERN.matcher("");
+					for (int i = 0; i < attrs.length; ++i) {
+						// strip out line breaks
+						String keyEntry = Joiner.on("").join(attrs[i].replace("\r\n", "\n").split("\n"));
+						m.reset(keyEntry);
+						try {
+							if (m.lookingAt()) { // Key is prefixed in LDAP
+								if (pkaPrefix == null) {
+									continue;
+								}
+								String prefix = m.group(1).trim();
+								if (! pkaPrefix.equalsIgnoreCase(prefix)) {
+									continue;
+								}
+								String s = keyEntry.substring(m.end()); // Strip prefix off
+								authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(s));
+
+							} else { // Key is not prefixed in LDAP
+								if (pkaPrefix != null) {
+									continue;
+								}
+								String s = keyEntry; // Strip prefix off
+								authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(s));
+							}
+						} catch (IllegalArgumentException e) {
+							log.info("Failed to parse key entry={}:", keyEntry, e.getMessage());
+						}
+					}
+
+					List<SshKey> keyList = new ArrayList<>(authorizedKeys.size());
+					for (GbAuthorizedKeyEntry keyEntry : authorizedKeys) {
+						try {
+							SshKey key = new SshKey(keyEntry.resolvePublicKey());
+							key.setComment(keyEntry.getComment());
+							setKeyPermissions(key, keyEntry);
+							keyList.add(key);
+						} catch (GeneralSecurityException | IOException e) {
+							log.warn("Error resolving key entry for user {}. Entry={}", username, keyEntry, e);
+						}
+					}
+					return keyList;
+				}
+			}
+		}
+
+		return null;
+	}
+
+
+	@Override
+	public boolean addKey(String username, SshKey key) {
+		return false;
+	}
+
+	@Override
+	public boolean removeKey(String username, SshKey key) {
+		return false;
+	}
+
+	@Override
+	public boolean removeAllKeys(String username) {
+		return false;
+	}
+
+
+	public boolean supportsWritingKeys(UserModel user) {
+		return false;
+	}
+
+	public boolean supportsCommentChanges(UserModel user) {
+		return false;
+	}
+
+	public boolean supportsPermissionChanges(UserModel user) {
+		return false;
+	}
+
+
+	private void setKeyPermissions(SshKey key, GbAuthorizedKeyEntry keyEntry) {
+		List<String> env = keyEntry.getLoginOptionValues("environment");
+		if (env != null && !env.isEmpty()) {
+			// Walk over all entries and find one that sets 'gbPerm'. The last one wins.
+			for (String envi : env) {
+				Matcher m = GB_PERM_PATTERN.matcher(envi);
+				if (m.find()) {
+					String perm = m.group(1).trim();
+					AccessPermission ap = AccessPermission.fromCode(perm);
+					if (ap == AccessPermission.NONE) {
+						ap = AccessPermission.valueOf(perm.toUpperCase());
+					}
+
+					if (ap != null && ap != AccessPermission.NONE) {
+						try {
+							key.setPermission(ap);
+						} catch (IllegalArgumentException e) {
+							log.warn("Incorrect permissions ({}) set for SSH key entry {}.", ap, envi, e);
+						}
+					}
+				}
+			}
+		}
+	}
+
+
+	/**
+	 * Returns a simple username without any domain prefixes.
+	 *
+	 * @param username
+	 * @return a simple username
+	 */
+	private String getSimpleUsername(String username) {
+		int lastSlash = username.lastIndexOf('\\');
+		if (lastSlash > -1) {
+			username = username.substring(lastSlash + 1);
+		}
+
+		return username;
+	}
+
+
+	/**
+	 * Extension of the AuthorizedKeyEntry from Mina SSHD with better option parsing.
+	 *
+	 * The class makes use of code from the two methods copied from the original
+	 * Mina SSHD AuthorizedKeyEntry class. The code is rewritten to improve user login
+	 * option support. Options are correctly parsed even if they have whitespace within
+	 * double quotes. Options can occur multiple times, which is needed for example for
+	 * the "environment" option. Thus for an option a list of strings is kept, holding
+	 * multiple option values.
+	 */
+	private static class GbAuthorizedKeyEntry extends AuthorizedKeyEntry {
+
+		private static final long serialVersionUID = 1L;
+		/**
+		 * Pattern to extract the first part of the key entry without whitespace or only with quoted whitespace.
+		 * The pattern essentially splits the line in two parts with two capturing groups. All other groups
+		 * in the pattern are non-capturing. The first part is a continuous string that only includes double quoted
+		 * whitespace and ends in whitespace. The second part is the rest of the line.
+		 * The first part is at the beginning of the line, the lead-in. For a SSH key entry this can either be
+		 * login options (see authorized keys file description) or the key type. Since options, other than the
+		 * key type, can include whitespace and escaped double quotes within double quotes, the pattern takes
+		 * care of that by searching for either "characters that are not whitespace and not double quotes"
+		 * or "a double quote, followed by 'characters that are not a double quote or backslash, or a backslash
+		 * and then a double quote, or a backslash', followed by a double quote".
+		 */
+		private static final Pattern LEADIN_PATTERN = Pattern.compile("^((?:[^\\s\"]*|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))*\\s+)(.+)");
+		/**
+		 * Pattern to split a comma separated list of options.
+		 * Since an option could contain commas (as well as escaped double quotes) within double quotes
+		 * in the option value, a simple split on comma is not enough. So the pattern searches for multiple
+		 * occurrences of:
+		 * characters that are not double quotes or a comma, or
+		 * a double quote followed by: characters that are not a double quote or backslash, or
+		 *                             a backslash and then a double quote, or
+		 *                             a backslash,
+		 *   followed by a double quote.
+		 */
+		private static final Pattern OPTION_PATTERN = Pattern.compile("([^\",]+|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))+");
+
+		// for options that have no value, "true" is used
+		private Map<String, List<String>> loginOptionsMulti = Collections.emptyMap();
+
+
+		List<String> getLoginOptionValues(String option) {
+			return loginOptionsMulti.get(option);
+		}
+
+
+
+		/**
+		 * @param line Original line from an <code>authorized_keys</code> file
+		 * @return {@link GbAuthorizedKeyEntry} or {@code null} if the line is
+		 * {@code null}/empty or a comment line
+		 * @throws IllegalArgumentException If failed to parse/decode the line
+		 * @see #COMMENT_CHAR
+		 */
+		public static GbAuthorizedKeyEntry parseAuthorizedKeyEntry(String line) throws IllegalArgumentException {
+			line = GenericUtils.trimToEmpty(line);
+			if (StringUtils.isEmpty(line) || (line.charAt(0) == COMMENT_CHAR) /* comment ? */) {
+				return null;
+			}
+
+			Matcher m = LEADIN_PATTERN.matcher(line);
+			if (! m.lookingAt()) {
+				throw new IllegalArgumentException("Bad format (no key data delimiter): " + line);
+			}
+
+			String keyType = m.group(1).trim();
+			final GbAuthorizedKeyEntry entry;
+			if (KeyUtils.getPublicKeyEntryDecoder(keyType) == null) {  // assume this is due to the fact that it starts with login options
+				entry = parseAuthorizedKeyEntry(m.group(2));
+				if (entry == null) {
+					throw new IllegalArgumentException("Bad format (no key data after login options): " + line);
+				}
+
+				entry.parseAndSetLoginOptions(keyType);
+			} else {
+				int startPos = line.indexOf(' ');
+				if (startPos <= 0) {
+					throw new IllegalArgumentException("Bad format (no key data delimiter): " + line);
+				}
+
+				int endPos = line.indexOf(' ', startPos + 1);
+				if (endPos <= startPos) {
+					endPos = line.length();
+				}
+
+				String encData = (endPos < (line.length() - 1)) ? line.substring(0, endPos).trim() : line;
+				String comment = (endPos < (line.length() - 1)) ? line.substring(endPos + 1).trim() : null;
+				entry = parsePublicKeyEntry(new GbAuthorizedKeyEntry(), encData);
+				entry.setComment(comment);
+			}
+
+			return entry;
+		}
+
+		private void parseAndSetLoginOptions(String options) {
+			Matcher m = OPTION_PATTERN.matcher(options);
+			if (! m.find()) {
+				loginOptionsMulti = Collections.emptyMap();
+			}
+			Map<String, List<String>> optsMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+
+			do {
+				String p = m.group();
+				p = GenericUtils.trimToEmpty(p);
+				if (StringUtils.isEmpty(p)) {
+					continue;
+				}
+
+				int pos = p.indexOf('=');
+				String name = (pos < 0) ? p : GenericUtils.trimToEmpty(p.substring(0, pos));
+				CharSequence value = (pos < 0) ? null : GenericUtils.trimToEmpty(p.substring(pos + 1));
+				value = GenericUtils.stripQuotes(value);
+
+				// For options without value the value is set to TRUE.
+				if (value == null) {
+					value = Boolean.TRUE.toString();
+				}
+
+				List<String> opts = optsMap.get(name);
+				if (opts == null) {
+					opts = new ArrayList<String>();
+					optsMap.put(name, opts);
+				}
+				opts.add(value.toString());
+			} while(m.find());
+
+			loginOptionsMulti = optsMap;
+		}
+	}
+
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java
index 5a94c9a..4fb05f7 100644
--- a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java
+++ b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java
@@ -134,7 +134,7 @@
 		sshd.setFileSystemFactory(new DisabledFilesystemFactory());
 		sshd.setTcpipForwardingFilter(new NonForwardingFilter());
 		sshd.setCommandFactory(new SshCommandFactory(gitblit, workQueue));
-		sshd.setShellFactory(new WelcomeShell(settings));
+		sshd.setShellFactory(new WelcomeShell(gitblit));
 
 		// Set the server id.  This can be queried with:
 		//   ssh-keyscan -t rsa,dsa -p 29418 localhost
diff --git a/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java b/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java
index ec6f729..7c407d3 100644
--- a/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java
+++ b/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java
@@ -34,6 +34,7 @@
 
 import com.gitblit.IStoredSettings;
 import com.gitblit.Keys;
+import com.gitblit.manager.IGitblit;
 import com.gitblit.models.UserModel;
 import com.gitblit.transport.ssh.commands.DispatchCommand;
 import com.gitblit.transport.ssh.commands.SshCommandFactory;
@@ -45,19 +46,20 @@
  */
 public class WelcomeShell implements Factory<Command> {
 
-	private final IStoredSettings settings;
+	private final IGitblit gitblit;
 
-	public WelcomeShell(IStoredSettings settings) {
-		this.settings = settings;
+	public WelcomeShell(IGitblit gitblit) {
+		this.gitblit = gitblit;
 	}
 
 	@Override
 	public Command create() {
-		return new SendMessage(settings);
+		return new SendMessage(gitblit);
 	}
 
 	private static class SendMessage implements Command, SessionAware {
 
+		private final IPublicKeyManager km;
 		private final IStoredSettings settings;
 		private ServerSession session;
 
@@ -66,8 +68,9 @@
 		private OutputStream err;
 		private ExitCallback exit;
 
-		SendMessage(IStoredSettings settings) {
-			this.settings = settings;
+		SendMessage(IGitblit gitblit) {
+			this.km = gitblit.getPublicKeyManager();
+			this.settings = gitblit.getSettings();
 		}
 
 		@Override
@@ -116,6 +119,10 @@
 			UserModel user = client.getUser();
 			String hostname = getHostname();
 			int port = settings.getInteger(Keys.git.sshPort, 0);
+			boolean writeKeysIsSupported = true;
+			if (km != null) {
+				writeKeysIsSupported = km.supportsWritingKeys(user);
+			}
 
 			final String b1 = StringUtils.rightPad("", 72, '═');
 			final String b2 = StringUtils.rightPad("", 72, '─');
@@ -159,7 +166,7 @@
 			msg.append(nl);
 			msg.append(nl);
 
-			if (client.getKey() == null) {
+			if (writeKeysIsSupported && client.getKey() == null) {
 				// user has authenticated with a password
 				// display add public key instructions
 				msg.append(" You may upload an SSH public key with the following syntax:");
diff --git a/src/main/java/com/gitblit/transport/ssh/keys/KeysDispatcher.java b/src/main/java/com/gitblit/transport/ssh/keys/KeysDispatcher.java
index da58584..817a98f 100644
--- a/src/main/java/com/gitblit/transport/ssh/keys/KeysDispatcher.java
+++ b/src/main/java/com/gitblit/transport/ssh/keys/KeysDispatcher.java
@@ -25,6 +25,7 @@
 import org.slf4j.LoggerFactory;
 
 import com.gitblit.Constants.AccessPermission;
+import com.gitblit.models.UserModel;
 import com.gitblit.transport.ssh.IPublicKeyManager;
 import com.gitblit.transport.ssh.SshKey;
 import com.gitblit.transport.ssh.commands.CommandMetaData;
@@ -47,12 +48,20 @@
 
 	@Override
 	protected void setup() {
-		register(AddKey.class);
-		register(RemoveKey.class);
+		IPublicKeyManager km = getContext().getGitblit().getPublicKeyManager();
+		UserModel user = getContext().getClient().getUser();
+		if (km != null && km.supportsWritingKeys(user)) {
+			register(AddKey.class);
+			register(RemoveKey.class);
+		}
 		register(ListKeys.class);
 		register(WhichKey.class);
-		register(CommentKey.class);
-		register(PermissionKey.class);
+		if (km != null && km.supportsCommentChanges(user)) {
+			register(CommentKey.class);
+		}
+		if (km != null && km.supportsPermissionChanges(user)) {
+			register(PermissionKey.class);
+		}
 	}
 
 	@CommandMetaData(name = "add", description = "Add an SSH public key to your account")
diff --git a/src/main/java/com/gitblit/utils/ArrayUtils.java b/src/main/java/com/gitblit/utils/ArrayUtils.java
index 1402ad5..b850ccc 100644
--- a/src/main/java/com/gitblit/utils/ArrayUtils.java
+++ b/src/main/java/com/gitblit/utils/ArrayUtils.java
@@ -42,7 +42,7 @@
 	}

 

 	public static boolean isEmpty(Collection<?> collection) {

-		return collection == null || collection.size() == 0;

+		return collection == null || collection.isEmpty();

 	}

 

 	public static String toString(Collection<?> collection) {

diff --git a/src/main/java/com/gitblit/utils/CommitCache.java b/src/main/java/com/gitblit/utils/CommitCache.java
index a3963f5..53b8de1 100644
--- a/src/main/java/com/gitblit/utils/CommitCache.java
+++ b/src/main/java/com/gitblit/utils/CommitCache.java
@@ -19,9 +19,9 @@
 import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 
 import org.eclipse.jgit.lib.ObjectId;
@@ -58,7 +58,7 @@
 	}
 
 	protected CommitCache() {
-		cache = new ConcurrentHashMap<String, ObjectCache<List<RepositoryCommit>>>();
+		cache = new HashMap<>();
 	}
 
 	/**
@@ -93,7 +93,9 @@
 	 *
 	 */
 	public void clear() {
-		cache.clear();
+		synchronized (cache) {
+			cache.clear();
+		}
 	}
 
 	/**
@@ -103,8 +105,11 @@
 	 */
 	public void clear(String repositoryName) {
 		String repoKey = repositoryName.toLowerCase();
-		ObjectCache<List<RepositoryCommit>> repoCache = cache.remove(repoKey);
-		if (repoCache != null) {
+		boolean hadEntries = false;
+		synchronized (cache) {
+			hadEntries = cache.remove(repoKey) != null;
+		}
+		if (hadEntries) {
 			logger.info(MessageFormat.format("{0} commit cache cleared", repositoryName));
 		}
 	}
@@ -117,13 +122,17 @@
 	 */
 	public void clear(String repositoryName, String branch) {
 		String repoKey = repositoryName.toLowerCase();
-		ObjectCache<List<RepositoryCommit>> repoCache = cache.get(repoKey);
-		if (repoCache != null) {
-			List<RepositoryCommit> commits = repoCache.remove(branch.toLowerCase());
-			if (!ArrayUtils.isEmpty(commits)) {
-				logger.info(MessageFormat.format("{0}:{1} commit cache cleared", repositoryName, branch));
+		boolean hadEntries = false;
+		synchronized (cache) {
+			ObjectCache<List<RepositoryCommit>> repoCache = cache.get(repoKey);
+			if (repoCache != null) {
+				List<RepositoryCommit> commits = repoCache.remove(branch.toLowerCase());
+				hadEntries = !ArrayUtils.isEmpty(commits);
 			}
 		}
+		if (hadEntries) {
+			logger.info(MessageFormat.format("{0}:{1} commit cache cleared", repositoryName, branch));
+		}
 	}
 
 	/**
@@ -156,49 +165,55 @@
 		if (cacheDays > 0 && (sinceDate.getTime() >= cacheCutoffDate.getTime())) {
 			// request fits within the cache window
 			String repoKey = repositoryName.toLowerCase();
-			if (!cache.containsKey(repoKey)) {
-				cache.put(repoKey, new ObjectCache<List<RepositoryCommit>>());
-			}
-
-			ObjectCache<List<RepositoryCommit>> repoCache = cache.get(repoKey);
 			String branchKey = branch.toLowerCase();
 
 			RevCommit tip = JGitUtils.getCommit(repository, branch);
 			Date tipDate = JGitUtils.getCommitDate(tip);
 
-			List<RepositoryCommit> commits;
-			if (!repoCache.hasCurrent(branchKey, tipDate)) {
-				commits = repoCache.getObject(branchKey);
-				if (ArrayUtils.isEmpty(commits)) {
-					// we don't have any cached commits for this branch, reload
-					commits = get(repositoryName, repository, branch, cacheCutoffDate);
-					repoCache.updateObject(branchKey, tipDate, commits);
-					logger.debug(MessageFormat.format("parsed {0} commits from {1}:{2} since {3,date,yyyy-MM-dd} in {4} msecs",
-							commits.size(), repositoryName, branch, cacheCutoffDate, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)));
-				} else {
-					// incrementally update cache since the last cached commit
-					ObjectId sinceCommit = commits.get(0).getId();
-					List<RepositoryCommit> incremental = get(repositoryName, repository, branch, sinceCommit);
-					logger.info(MessageFormat.format("incrementally added {0} commits to cache for {1}:{2} in {3} msecs",
-							incremental.size(), repositoryName, branch, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)));
-					incremental.addAll(commits);
-					repoCache.updateObject(branchKey, tipDate, incremental);
-					commits = incremental;
+			ObjectCache<List<RepositoryCommit>> repoCache;
+			synchronized (cache) {
+				repoCache = cache.get(repoKey);
+				if (repoCache == null) {
+					repoCache = new ObjectCache<>();
+					cache.put(repoKey, repoCache);
 				}
-			} else {
-				// cache is current
-				commits = repoCache.getObject(branchKey);
-				// evict older commits outside the cache window
-				commits = reduce(commits, cacheCutoffDate);
-				// update cache
-				repoCache.updateObject(branchKey, tipDate, commits);
 			}
+			synchronized (repoCache) {
+				List<RepositoryCommit> commits;
+				if (!repoCache.hasCurrent(branchKey, tipDate)) {
+					commits = repoCache.getObject(branchKey);
+					if (ArrayUtils.isEmpty(commits)) {
+						// we don't have any cached commits for this branch, reload
+						commits = get(repositoryName, repository, branch, cacheCutoffDate);
+						repoCache.updateObject(branchKey, tipDate, commits);
+						logger.debug(MessageFormat.format("parsed {0} commits from {1}:{2} since {3,date,yyyy-MM-dd} in {4} msecs",
+								commits.size(), repositoryName, branch, cacheCutoffDate, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)));
+					} else {
+						// incrementally update cache since the last cached commit
+						ObjectId sinceCommit = commits.get(0).getId();
+						List<RepositoryCommit> incremental = get(repositoryName, repository, branch, sinceCommit);
+						logger.info(MessageFormat.format("incrementally added {0} commits to cache for {1}:{2} in {3} msecs",
+								incremental.size(), repositoryName, branch, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)));
+						incremental.addAll(commits);
+						repoCache.updateObject(branchKey, tipDate, incremental);
+						commits = incremental;
+					}
+				} else {
+					// cache is current
+					commits = repoCache.getObject(branchKey);
+					// evict older commits outside the cache window
+					commits = reduce(commits, cacheCutoffDate);
+					// update cache
+					repoCache.updateObject(branchKey, tipDate, commits);
+				}
 
-			if (sinceDate.equals(cacheCutoffDate)) {
-				list = commits;
-			} else {
-				// reduce the commits to those since the specified date
-				list = reduce(commits, sinceDate);
+				if (sinceDate.equals(cacheCutoffDate)) {
+					// Mustn't hand out the cached list; that's not thread-safe
+					list = new ArrayList<>(commits);
+				} else {
+					// reduce the commits to those since the specified date
+					list = reduce(commits, sinceDate);
+				}
 			}
 			logger.debug(MessageFormat.format("retrieved {0} commits from cache of {1}:{2} since {3,date,yyyy-MM-dd} in {4} msecs",
 					list.size(), repositoryName, branch, sinceDate, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)));
@@ -222,8 +237,9 @@
 	 */
 	protected List<RepositoryCommit> get(String repositoryName, Repository repository, String branch, Date sinceDate) {
 		Map<ObjectId, List<RefModel>> allRefs = JGitUtils.getAllRefs(repository, false);
-		List<RepositoryCommit> commits = new ArrayList<RepositoryCommit>();
-		for (RevCommit commit : JGitUtils.getRevLog(repository, branch, sinceDate)) {
+		List<RevCommit> revLog = JGitUtils.getRevLog(repository, branch, sinceDate);
+		List<RepositoryCommit> commits = new ArrayList<RepositoryCommit>(revLog.size());
+		for (RevCommit commit : revLog) {
 			RepositoryCommit commitModel = new RepositoryCommit(repositoryName, branch, commit);
 			List<RefModel> commitRefs = allRefs.get(commitModel.getId());
 			commitModel.setRefs(commitRefs);
@@ -243,8 +259,9 @@
 	 */
 	protected List<RepositoryCommit> get(String repositoryName, Repository repository, String branch, ObjectId sinceCommit) {
 		Map<ObjectId, List<RefModel>> allRefs = JGitUtils.getAllRefs(repository, false);
-		List<RepositoryCommit> commits = new ArrayList<RepositoryCommit>();
-		for (RevCommit commit : JGitUtils.getRevLog(repository, sinceCommit.getName(), branch)) {
+		List<RevCommit> revLog = JGitUtils.getRevLog(repository, sinceCommit.getName(), branch);
+		List<RepositoryCommit> commits = new ArrayList<RepositoryCommit>(revLog.size());
+		for (RevCommit commit : revLog) {
 			RepositoryCommit commitModel = new RepositoryCommit(repositoryName, branch, commit);
 			List<RefModel> commitRefs = allRefs.get(commitModel.getId());
 			commitModel.setRefs(commitRefs);
@@ -261,7 +278,7 @@
 	 * @return  a list of commits
 	 */
 	protected List<RepositoryCommit> reduce(List<RepositoryCommit> commits, Date sinceDate) {
-		List<RepositoryCommit> filtered = new ArrayList<RepositoryCommit>();
+		List<RepositoryCommit> filtered = new ArrayList<RepositoryCommit>(commits.size());
 		for (RepositoryCommit commit : commits) {
 			if (commit.getCommitDate().compareTo(sinceDate) >= 0) {
 				filtered.add(commit);
diff --git a/src/main/java/com/gitblit/utils/JGitUtils.java b/src/main/java/com/gitblit/utils/JGitUtils.java
index a02fc3f..0eea1d6 100644
--- a/src/main/java/com/gitblit/utils/JGitUtils.java
+++ b/src/main/java/com/gitblit/utils/JGitUtils.java
@@ -99,6 +99,7 @@
 import org.slf4j.Logger;

 import org.slf4j.LoggerFactory;

 

+import com.gitblit.Constants.MergeType;

 import com.gitblit.GitBlitException;

 import com.gitblit.IStoredSettings;

 import com.gitblit.Keys;

@@ -2453,44 +2454,13 @@
 	 * @param repository

 	 * @param src

 	 * @param toBranch

+	 * @param mergeType

+	 *            Defines the integration strategy to use for merging.

 	 * @return true if we can merge without conflict

 	 */

-	public static MergeStatus canMerge(Repository repository, String src, String toBranch) {

-		RevWalk revWalk = null;

-		try {

-			revWalk = new RevWalk(repository);

-			ObjectId branchId = repository.resolve(toBranch);

-			if (branchId == null) {

-				return MergeStatus.MISSING_INTEGRATION_BRANCH;

-			}

-			ObjectId srcId = repository.resolve(src);

-			if (srcId == null) {

-				return MergeStatus.MISSING_SRC_BRANCH;

-			}

-			RevCommit branchTip = revWalk.lookupCommit(branchId);

-			RevCommit srcTip = revWalk.lookupCommit(srcId);

-			if (revWalk.isMergedInto(srcTip, branchTip)) {

-				// already merged

-				return MergeStatus.ALREADY_MERGED;

-			} else if (revWalk.isMergedInto(branchTip, srcTip)) {

-				// fast-forward

-				return MergeStatus.MERGEABLE;

-			}

-			RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true);

-			boolean canMerge = merger.merge(branchTip, srcTip);

-			if (canMerge) {

-				return MergeStatus.MERGEABLE;

-			}

-		} catch (NullPointerException e) {

-			LOGGER.error("Failed to determine canMerge", e);

-		} catch (IOException e) {

-			LOGGER.error("Failed to determine canMerge", e);

-		} finally {

-			if (revWalk != null) {

-				revWalk.close();

-			}

-		}

-		return MergeStatus.NOT_MERGEABLE;

+	public static MergeStatus canMerge(Repository repository, String src, String toBranch, MergeType mergeType) {

+		IntegrationStrategy strategy = IntegrationStrategyFactory.create(mergeType, repository, src, toBranch);

+		return strategy.canMerge();

 	}

 

 

@@ -2511,11 +2481,13 @@
 	 * @param repository

 	 * @param src

 	 * @param toBranch

+	 * @param mergeType

+	 *            Defines the integration strategy to use for merging.

 	 * @param committer

 	 * @param message

 	 * @return the merge result

 	 */

-	public static MergeResult merge(Repository repository, String src, String toBranch,

+	public static MergeResult merge(Repository repository, String src, String toBranch, MergeType mergeType,

 			PersonIdent committer, String message) {

 

 		if (!toBranch.startsWith(Constants.R_REFS)) {

@@ -2523,15 +2495,202 @@
 			toBranch = Constants.R_HEADS + toBranch;

 		}

 

-		RevWalk revWalk = null;

+		IntegrationStrategy strategy = IntegrationStrategyFactory.create(mergeType, repository, src, toBranch);

+		MergeResult mergeResult = strategy.merge(committer, message);

+

+		if (mergeResult.status != MergeStatus.MERGED) {

+			return mergeResult;

+		}

+

 		try {

-			revWalk = new RevWalk(repository);

-			RevCommit branchTip = revWalk.lookupCommit(repository.resolve(toBranch));

-			RevCommit srcTip = revWalk.lookupCommit(repository.resolve(src));

-			if (revWalk.isMergedInto(srcTip, branchTip)) {

-				// already merged

-				return new MergeResult(MergeStatus.ALREADY_MERGED, null);

+			// Update the integration branch ref

+			RefUpdate mergeRefUpdate = repository.updateRef(toBranch);

+			mergeRefUpdate.setNewObjectId(strategy.getMergeCommit());

+			mergeRefUpdate.setRefLogMessage(strategy.getRefLogMessage(), false);

+			mergeRefUpdate.setExpectedOldObjectId(strategy.branchTip);

+			RefUpdate.Result rc = mergeRefUpdate.update();

+			switch (rc) {

+			case FAST_FORWARD:

+				// successful, clean merge

+				break;

+			default:

+				mergeResult = new MergeResult(MergeStatus.FAILED, null);

+				throw new GitBlitException(MessageFormat.format("Unexpected result \"{0}\" when {1} in {2}",

+						rc.name(), strategy.getOperationMessage(), repository.getDirectory()));

 			}

+		} catch (IOException e) {

+			LOGGER.error("Failed to merge", e);

+		}

+

+		return mergeResult;

+	}

+

+

+	private static abstract class IntegrationStrategy {

+		Repository repository;

+		String src;

+		String toBranch;

+

+		RevWalk revWalk;

+		RevCommit branchTip;

+		RevCommit srcTip;

+

+		RevCommit mergeCommit;

+		String refLogMessage;

+		String operationMessage;

+

+		RevCommit getMergeCommit() {

+			return mergeCommit;

+		}

+

+		String getRefLogMessage() {

+			return refLogMessage;

+		}

+

+		String getOperationMessage() {

+			return operationMessage;

+		}

+

+		IntegrationStrategy(Repository repository, String src, String toBranch) {

+			this.repository = repository;

+			this.src = src;

+			this.toBranch = toBranch;

+		}

+

+		void prepare() throws IOException {

+			if (revWalk == null) revWalk = new RevWalk(repository);

+			ObjectId branchId = repository.resolve(toBranch);

+			if (branchId != null) {

+				branchTip = revWalk.lookupCommit(branchId);

+			}

+			ObjectId srcId = repository.resolve(src);

+			if (srcId != null) {

+				srcTip = revWalk.lookupCommit(srcId);

+			}

+		}

+

+

+		abstract MergeStatus _canMerge() throws IOException;

+

+

+		MergeStatus canMerge() {

+			try {

+				prepare();

+				if (branchTip == null) {

+					return MergeStatus.MISSING_INTEGRATION_BRANCH;

+				}

+				if (srcTip == null) {

+					return MergeStatus.MISSING_SRC_BRANCH;

+				}

+				if (revWalk.isMergedInto(srcTip, branchTip)) {

+					// already merged

+					return MergeStatus.ALREADY_MERGED;

+				}

+				// determined by specific integration strategy

+				return _canMerge();

+

+			} catch (NullPointerException e) {

+				LOGGER.error("Failed to determine canMerge", e);

+			} catch (IOException e) {

+				LOGGER.error("Failed to determine canMerge", e);

+			} finally {

+				if (revWalk != null) {

+					revWalk.close();

+				}

+			}

+

+			return MergeStatus.NOT_MERGEABLE;

+		}

+

+

+		abstract MergeResult _merge(PersonIdent committer, String message) throws IOException;

+

+

+		MergeResult merge(PersonIdent committer, String message) {

+			try {

+				prepare();

+				if (revWalk.isMergedInto(srcTip, branchTip)) {

+					// already merged

+					return new MergeResult(MergeStatus.ALREADY_MERGED, null);

+				}

+				// determined by specific integration strategy

+				return _merge(committer, message);

+

+			} catch (IOException e) {

+				LOGGER.error("Failed to merge", e);

+			} finally {

+				if (revWalk != null) {

+					revWalk.close();

+				}

+			}

+

+			return new MergeResult(MergeStatus.FAILED, null);

+		}

+	}

+

+

+	private static class FastForwardOnly extends IntegrationStrategy {

+		FastForwardOnly(Repository repository, String src, String toBranch) {

+			super(repository, src, toBranch);

+		}

+

+		@Override

+		MergeStatus _canMerge() throws IOException {

+			if (revWalk.isMergedInto(branchTip, srcTip)) {

+				// fast-forward

+				return MergeStatus.MERGEABLE;

+			}

+

+			return MergeStatus.NOT_MERGEABLE;

+		}

+

+		@Override

+		MergeResult _merge(PersonIdent committer, String message) throws IOException {

+			if (! revWalk.isMergedInto(branchTip, srcTip)) {

+				// is not fast-forward

+				return new MergeResult(MergeStatus.FAILED, null);

+			}

+

+			mergeCommit = srcTip;

+			refLogMessage = "merge " + src + ": Fast-forward";

+			operationMessage = MessageFormat.format("fast-forwarding {0} to commit {1}", srcTip.getName(), branchTip.getName());

+

+			return new MergeResult(MergeStatus.MERGED, srcTip.getName());

+		}

+	}

+

+	private static class MergeIfNecessary extends IntegrationStrategy {

+		MergeIfNecessary(Repository repository, String src, String toBranch) {

+			super(repository, src, toBranch);

+		}

+

+		@Override

+		MergeStatus _canMerge() throws IOException {

+			if (revWalk.isMergedInto(branchTip, srcTip)) {

+				// fast-forward

+				return MergeStatus.MERGEABLE;

+			}

+

+			RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true);

+			boolean canMerge = merger.merge(branchTip, srcTip);

+			if (canMerge) {

+				return MergeStatus.MERGEABLE;

+			}

+

+			return MergeStatus.NOT_MERGEABLE;

+		}

+

+		@Override

+		MergeResult _merge(PersonIdent committer, String message) throws IOException {

+			if (revWalk.isMergedInto(branchTip, srcTip)) {

+				// fast-forward

+				mergeCommit = srcTip;

+				refLogMessage = "merge " + src + ": Fast-forward";

+				operationMessage = MessageFormat.format("fast-forwarding {0} to commit {1}", branchTip.getName(), srcTip.getName());

+

+				return new MergeResult(MergeStatus.MERGED, srcTip.getName());

+			}

+

 			RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true);

 			boolean merged = merger.merge(branchTip, srcTip);

 			if (merged) {

@@ -2555,20 +2714,9 @@
 					ObjectId mergeCommitId = odi.insert(commitBuilder);

 					odi.flush();

 

-					// set the merge ref to the merge commit

-					RevCommit mergeCommit = revWalk.parseCommit(mergeCommitId);

-					RefUpdate mergeRefUpdate = repository.updateRef(toBranch);

-					mergeRefUpdate.setNewObjectId(mergeCommitId);

-					mergeRefUpdate.setRefLogMessage("commit: " + mergeCommit.getShortMessage(), false);

-					RefUpdate.Result rc = mergeRefUpdate.update();

-					switch (rc) {

-					case FAST_FORWARD:

-						// successful, clean merge

-						break;

-					default:

-						throw new GitBlitException(MessageFormat.format("Unexpected result \"{0}\" when merging commit {1} into {2} in {3}",

-								rc.name(), srcTip.getName(), branchTip.getName(), repository.getDirectory()));

-					}

+					mergeCommit = revWalk.parseCommit(mergeCommitId);

+					refLogMessage = "commit: " + mergeCommit.getShortMessage();

+					operationMessage = MessageFormat.format("merging commit {0} into {1}", srcTip.getName(), branchTip.getName());

 

 					// return the merge commit id

 					return new MergeResult(MergeStatus.MERGED, mergeCommitId.getName());

@@ -2576,17 +2724,82 @@
 					odi.close();

 				}

 			}

-		} catch (IOException e) {

-			LOGGER.error("Failed to merge", e);

-		} finally {

-			if (revWalk != null) {

-				revWalk.close();

-			}

+			return new MergeResult(MergeStatus.FAILED, null);

 		}

-		return new MergeResult(MergeStatus.FAILED, null);

 	}

-	

-	

+

+	private static class MergeAlways extends IntegrationStrategy {

+		MergeAlways(Repository repository, String src, String toBranch) {

+			super(repository, src, toBranch);

+		}

+

+		@Override

+		MergeStatus _canMerge() throws IOException {

+			RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true);

+			boolean canMerge = merger.merge(branchTip, srcTip);

+			if (canMerge) {

+				return MergeStatus.MERGEABLE;

+			}

+

+			return MergeStatus.NOT_MERGEABLE;

+		}

+

+		@Override

+		MergeResult _merge(PersonIdent committer, String message) throws IOException {

+			RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true);

+			boolean merged = merger.merge(branchTip, srcTip);

+			if (merged) {

+				// create a merge commit and a reference to track the merge commit

+				ObjectId treeId = merger.getResultTreeId();

+				ObjectInserter odi = repository.newObjectInserter();

+				try {

+					// Create a commit object

+					CommitBuilder commitBuilder = new CommitBuilder();

+					commitBuilder.setCommitter(committer);

+					commitBuilder.setAuthor(committer);

+					commitBuilder.setEncoding(Constants.CHARSET);

+					if (StringUtils.isEmpty(message)) {

+						message = MessageFormat.format("merge {0} into {1}", srcTip.getName(), branchTip.getName());

+					}

+					commitBuilder.setMessage(message);

+					commitBuilder.setParentIds(branchTip.getId(), srcTip.getId());

+					commitBuilder.setTreeId(treeId);

+

+					// Insert the merge commit into the repository

+					ObjectId mergeCommitId = odi.insert(commitBuilder);

+					odi.flush();

+

+					mergeCommit = revWalk.parseCommit(mergeCommitId);

+					refLogMessage = "commit: " + mergeCommit.getShortMessage();

+					operationMessage = MessageFormat.format("merging commit {0} into {1}", srcTip.getName(), branchTip.getName());

+

+					// return the merge commit id

+					return new MergeResult(MergeStatus.MERGED, mergeCommitId.getName());

+				} finally {

+					odi.close();

+				}

+			}

+

+			return new MergeResult(MergeStatus.FAILED, null);

+		}

+	}

+

+

+	private static class IntegrationStrategyFactory {

+		static IntegrationStrategy create(MergeType mergeType, Repository repository, String src, String toBranch) {

+			switch(mergeType) {

+			case FAST_FORWARD_ONLY:

+				return new FastForwardOnly(repository, src, toBranch);

+			case MERGE_IF_NECESSARY:

+				return new MergeIfNecessary(repository, src, toBranch);

+			case MERGE_ALWAYS:

+				return new MergeAlways(repository, src, toBranch);

+			}

+			return null;

+		}

+	}

+

+

 	/**

 	 * Returns the LFS URL for the given oid 

 	 * Currently assumes that the Gitblit Filestore is used 

diff --git a/src/main/java/com/gitblit/utils/MarkdownUtils.java b/src/main/java/com/gitblit/utils/MarkdownUtils.java
index e0c9dd4..8371b3c 100644
--- a/src/main/java/com/gitblit/utils/MarkdownUtils.java
+++ b/src/main/java/com/gitblit/utils/MarkdownUtils.java
@@ -30,6 +30,7 @@
 import org.pegdown.PegDownProcessor;

 import org.pegdown.ast.RootNode;

 

+import com.gitblit.Constants;

 import com.gitblit.IStoredSettings;

 import com.gitblit.Keys;

 import com.gitblit.wicket.MarkupProcessor.WorkaroundHtmlSerializer;

@@ -137,8 +138,8 @@
 		String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");

 

 		// emphasize and link mentions

-		String mentionReplacement = String.format(" **[@$1](%1s/user/$1)**", canonicalUrl);

-		text = text.replaceAll("\\s@([A-Za-z0-9-_]+)", mentionReplacement);

+		String mentionReplacement = String.format("**[@${user}](%1s/user/${user})**", canonicalUrl);

+		text = text.replaceAll(Constants.REGEX_TICKET_MENTION, mentionReplacement);

 

 		// link ticket refs
 		String ticketReplacement = MessageFormat.format("$1[#$2]({0}/tickets?r={1}&h=$2)$3", canonicalUrl, repositoryName);
diff --git a/src/main/java/com/gitblit/utils/SecureRandom.java b/src/main/java/com/gitblit/utils/SecureRandom.java
new file mode 100644
index 0000000..119533d
--- /dev/null
+++ b/src/main/java/com/gitblit/utils/SecureRandom.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2016 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.utils;
+
+/**
+ * Wrapper class for java.security.SecureRandom, which will periodically reseed
+ * the PRNG in case an instance of the class has been running for a long time.
+ *
+ * @author Florian Zschocke
+ */
+public class SecureRandom {
+
+	/** Period (in ms) after which a new SecureRandom will be created in order to get a fresh random seed. */
+	private static final long RESEED_PERIOD = 24 * 60 * 60 * 1000; /* 24 hours */
+
+
+	private long last;
+	private java.security.SecureRandom random;
+
+
+
+	public SecureRandom() {
+		// Make sure the SecureRandom is seeded right from the start.
+		// This also lets any blocks during seeding occur at creation
+		// and prevents it from happening when getting next random bytes.
+		seed();
+	}
+
+
+
+	public byte[] randomBytes(int num) {
+		byte[] bytes = new byte[num];
+		nextBytes(bytes);
+		return bytes;
+	}
+
+
+	public void nextBytes(byte[] bytes) {
+		random.nextBytes(bytes);
+		reseed(false);
+	}
+
+
+	void reseed(boolean forced) {
+		long ts = System.currentTimeMillis();
+		if (forced || (ts - last) > RESEED_PERIOD) {
+			last = ts;
+			runReseed();
+		}
+	}
+
+
+
+	private void seed() {
+		random = new java.security.SecureRandom();
+		random.nextBytes(new byte[0]);
+		last = System.currentTimeMillis();
+	}
+
+
+	private void runReseed() {
+		// Have some other thread hit the penalty potentially incurred by reseeding,
+		// so that we can immediately return and not block the operation in progress.
+		new Thread() {
+			public void run() {
+				seed();
+			}
+		}.start();
+	}
+}
diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
index a215b4d..b3cbef8 100644
--- a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
+++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
@@ -660,6 +660,7 @@
 gb.body = body
 gb.mergeSha = mergeSha
 gb.mergeTo = merge to
+gb.mergeType = merge type
 gb.labels = labels
 gb.reviewers = reviewers
 gb.voters = voters
@@ -671,6 +672,7 @@
 gb.serverDoesNotAcceptPatchsets = This server does not accept patchsets.
 gb.ticketIsClosed = This ticket is closed.
 gb.mergeToDescription = default integration branch for merging ticket patchsets
+gb.mergeTypeDescription = merge a ticket fast-forward only, if necessary, or always with a merge commit to the integration branch
 gb.anonymousCanNotPropose = Anonymous users can not propose patchsets.
 gb.youDoNotHaveClonePermission = You are not permitted to clone this repository.
 gb.myTickets = my tickets
diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp_nl.properties b/src/main/java/com/gitblit/wicket/GitBlitWebApp_nl.properties
index f43b8f5..f71d67d 100644
--- a/src/main/java/com/gitblit/wicket/GitBlitWebApp_nl.properties
+++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp_nl.properties
@@ -476,7 +476,7 @@
 gb.pushedNewTag = push nieuwe tag
 gb.createdNewTag = nieuwe tag gemaakt
 gb.deletedTag = tag verwijderd
-gb.pushedNewBranch = push neuwe branch
+gb.pushedNewBranch = push nieuwe branch
 gb.createdNewBranch = nieuwe branch gemaakt
 gb.deletedBranch = branch verwijderd
 gb.createdNewPullRequest = pull verzoek gemaakt
diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp_zh_TW.properties b/src/main/java/com/gitblit/wicket/GitBlitWebApp_zh_TW.properties
index 16a9c86..bf2d2c3 100644
--- a/src/main/java/com/gitblit/wicket/GitBlitWebApp_zh_TW.properties
+++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp_zh_TW.properties
@@ -1,772 +1,783 @@
 #!
-#! created/edited by Popeye version 0.54 (popeye.sourceforge.net)
+#! created/edited by Popeye version 0.55 (https://github.com/koppor/popeye)
 #! encoding:ISO-8859-1
-gb.about = \u95dc\u65bc
-gb.acceptNewPatchsets = \u5141\u8a31\u88dc\u4e01
-gb.acceptNewPatchsetsDescription = \u63a5\u53d7\u5230\u7248\u672c\u5009\u9032\u884c\u4fee\u88dc\u52d5\u4f5c
-gb.acceptNewTickets = \u5141\u8a31\u5efa\u7acb\u4efb\u52d9\u55ae
-gb.acceptNewTicketsDescription = \u5141\u8a31\u65b0\u589e"\u81ed\u87f2","\u512a\u5316","\u4efb\u52d9"\u5404\u985e\u578b\u4efb\u52d9\u55ae
-gb.accessDenied = \u62d2\u7d55\u5b58\u53d6
-gb.accessLevel = \u5b58\u53d6\u7b49\u7d1a
-gb.accessPermissions = \u5b58\u53d6\u6b0a\u9650
-gb.accessPermissionsDescription = restrict access by users and teams
-gb.accessPermissionsForTeamDescription = set team members and grant access to specific restricted repositories
-gb.accessPermissionsForUserDescription = set team memberships or grant access to specific restricted repositories
-gb.accessPolicy = \u5b58\u53d6\u653f\u7b56
-gb.accessPolicyDescription = \u9078\u64c7\u7528\u4f86\u63a7\u5236\u6587\u4ef6\u5eab\u7684\u5b58\u53d6\u653f\u7b56\u4ee5\u53ca\u6b0a\u9650\u8a2d\u5b9a
-gb.accessRestriction = \u9650\u5236\u5b58\u53d6
-gb.accountPreferences = \u5e33\u865f\u8a2d\u5b9a
-gb.accountPreferencesDescription = \u8a2d\u5b9a\u5e33\u865f\u9810\u8a2d\u503c
-gb.action = \u52d5\u4f5c
-gb.active = \u6d3b\u52d5
-gb.activeAuthors = \u6d3b\u8e8d\u7528\u6236
-gb.activeRepositories = \u6d3b\u8e8d\u7248\u672c\u5eab
-gb.activity = \u6d3b\u52d5
-gb.add = \u65b0\u589e
-gb.addComment = \u65b0\u589e\u8a3b\u89e3
-gb.addedNCommits = {0}\u500b\u6a94\u6848\u63d0\u4ea4\u5b8c\u7562
-gb.addedOneCommit = \u63d0\u4ea41\u500b\u6a94\u6848
-gb.addition = addition
-gb.addSshKey = \u65b0\u589e SSH Key
-gb.administration = \u7ba1\u7406\u6b0a\u9650
-gb.administrator = \u7ba1\u7406\u54e1
-gb.administratorPermission = Gitblit \u7ba1\u7406\u54e1
-gb.affiliationChanged = affiliation changed
-gb.age = \u6642\u9593
-gb.all = \u5168\u90e8
-gb.allBranches = \u6240\u6709\u5206\u652f
-gb.allowAuthenticatedDescription = \u6279\u51c6 RW+ \u6b0a\u9650\u7d66\u4e88\u5c08\u6848\u6210\u54e1
-gb.allowForks = \u5141\u8a31\u5efa\u7acb\u5206\u652f(forks)
-gb.allowForksDescription = \u5141\u8a31\u5df2\u6388\u6b0a\u7684\u4f7f\u7528\u8005\u5f9e\u6587\u4ef6\u5eab\u5efa\u7acb\u5206\u652f(fork)
-gb.allowNamedDescription = grant fine-grained permissions to named users or teams
-gb.allProjects = \u5168\u90e8\u7fa4\u7d44
-gb.allTags = \u6240\u6709\u6a19\u7c64
-gb.anonymousCanNotPropose = \u533f\u540d\u8005\u4e0d\u80fd\u63d0\u4f9b\u88dc\u4e01
-gb.anonymousPolicy = \u533f\u540d\u72c0\u614b\u53ef\u4ee5View, Clone\u8207Push
-gb.anonymousPolicyDescription = \u4efb\u4f55\u4eba\u53ef\u6aa2\u8996,\u8907\u88fd(clone)\u8207\u63a8\u9001(push)\u6587\u4ef6\u5230\u6587\u4ef6\u5eab
-gb.anonymousUser= \u533f\u540d\u72c0\u614b
-gb.any = \u4efb\u4f55
-gb.approve = \u901a\u904e
-gb.at = at
-gb.attributes = \u5c6c\u6027
-gb.authenticatedPushPolicy = Restrict Push (Authenticated)
-gb.authenticatedPushPolicyDescription = \u4efb\u4f55\u4eba\u53ef\u4ee5\u6aa2\u8996\u8207\u8907\u88fd(clone).\u6240\u6709\u6587\u4ef6\u5eab\u6210\u54e1\u7686\u6709RW+\u8207\u63a8\u9001(push)\u529f\u80fd.
-gb.author = \u4f5c\u8005
-gb.authored = \u5df2\u6388\u6b0a
-gb.authorizationControl = \u6388\u6b0a\u7ba1\u63a7
-gb.available = \u53ef\u7528
-gb.blame = \u7a76\u67e5
-gb.blinkComparator = Blink comparator
-gb.blob = \u5340\u584a
-gb.body = body
-gb.bootDate = \u555f\u52d5\u65e5
-gb.branch = \u5206\u652f
-gb.branches = \u5206\u652f
-gb.branchStats = \u9019\u500b\u5206\u652f{2}\u6709{0}\u500b\u63d0\u4ea4\u4ee5\u53ca{1}\u500b\u6a19\u7c64
-gb.browse = \u700f\u89bd
-gb.bugTickets = \u81ed\u87f2
-gb.busyCollectingGarbage = \u62b1\u6b49,Gitblit\u6b63\u5728\u56de\u6536\u7cfb\u7d71\u8cc7\u6e90\u4e2d:{0}
-gb.byNAuthors = \u7d93\u7531{0}\u500b\u4f5c\u8005
-gb.byOneAuthor = \u7d93\u7531{0}
-gb.caCompromise = CA compromise
-gb.canAdmin = \u53ef\u7ba1\u7406
-gb.canAdminDescription = \u53ef\u7ba1\u7406Gitblit\u4f3a\u670d\u5668
-gb.cancel = \u53d6\u6d88
-gb.canCreate = \u53ef\u5efa\u7acb
-gb.canCreateDescription = \u80fd\u5920\u5efa\u7acb\u79c1\u4eba\u6587\u4ef6\u5eab
-gb.canFork = \u53ef\u5efa\u7acb\u5206\u652f(fork)
-gb.canForkDescription = \u53ef\u4ee5\u5efa\u7acb\u6587\u4ef6\u5eab\u5206\u652f(fork),\u4e26\u4e14\u8907\u88fd\u5230\u79c1\u4eba\u6587\u4ef6\u5eab\u4e2d
-gb.canNotLoadRepository = \u7121\u6cd5\u8f09\u5165\u7248\u672c\u5eab
-gb.canNotProposePatchset = \u4e0d\u80fd\u63d0\u4f9b\u88dc\u4e01
-gb.certificate = \u8b49\u66f8
-gb.certificateRevoked = \u8b49\u66f8{0,number,0} \u5df2\u7d93\u88ab\u53d6\u6d88
-gb.certificates = \u8b49\u66f8
-gb.cessationOfOperation = cessation of operation
-gb.changedFiles = \u5df2\u8b8a\u66f4\u904e\u7684\u6a94\u6848
-gb.changedStatus = changed the status
-gb.changePassword = \u4fee\u6539\u5bc6\u78bc
-gb.checkout = \u6aa2\u51fa(checkout)
-gb.checkoutStep1 = Fetch the current patchset \u2014 run this from your project directory
-gb.checkoutStep2 = \u5c07\u8a72\u88dc\u4e01\u8f49\u51fa\u5230\u65b0\u7684\u5206\u652f\u7136\u5f8c\u7528\u4f86\u6aa2\u8996
-gb.checkoutViaCommandLine = \u4f7f\u7528\u6307\u4ee4Checkout
-gb.checkoutViaCommandLineNote = \u4f60\u53ef\u4ee5\u5f9e\u4f60\u6587\u4ef6\u5eab\u4e2dcheckout\u4e00\u4efd,\u7136\u5f8c\u9032\u884c\u6e2c\u8a66
-gb.clearCache = \u6e05\u9664\u5feb\u53d6
-gb.clientCertificateBundleSent = {0}\u7684\u7528\u6236\u8b49\u66f8\u5df2\u5bc4\u767c
-gb.clientCertificateGenerated = \u6210\u529f\u7522\u751f{0}\u7684\u65b0\u8b49\u66f8
-gb.clone = \u8907\u88fd(clone)
-gb.clonePermission = {0} \u8907\u88fd(clone)
-gb.clonePolicy = Restrict Clone & Push
-gb.clonePolicyDescription = \u4efb\u4f55\u4eba\u53ef\u4ee5\u770b\u6587\u4ef6\u5eab. \u4f46\u4f60\u80fd\u5920\u8907\u88fd(clone)\u8207\u63a8\u9001(push)
-gb.cloneRestricted = authenticated clone & push
-gb.closeBrowser = \u8acb\u95dc\u9589\u700f\u89bd\u5668\u7d50\u675f\u6b64\u767b\u5165\u968e\u6bb5
-gb.closed = \u95dc\u9589
-gb.closedMilestones = \u5df2\u95dc\u9589\u7684\u91cc\u7a0b\u7891(milestones)
-gb.combinedMd5Rename = Gitblit\u4f7f\u7528md5\u65b9\u5f0f\u5c07\u5bc6\u78bc\u7de8\u78bc(\u7121\u6cd5\u9084\u539f).\u4f60\u5fc5\u9808\u8f38\u5165\u65b0\u5bc6\u78bc.
-gb.comment = \u8a3b\u89e3
-gb.commented = \u5df2\u8a3b\u89e3
-gb.comments = \u8a3b\u89e3
-gb.commit = \u63d0\u4ea4
-gb.commitActivityAuthors = \u63d0\u4ea4\u6d3b\u8e8d\u7387(\u4f7f\u7528\u8005)
-gb.commitActivityDOW = \u6bcf(\u65e5)\u9031\u63d0\u4ea4
-gb.commitActivityTrend = \u63d0\u4ea4\u8da8\u52e2\u5716
-gb.commitdiff = \u63d0\u4ea4\u5dee\u7570
-gb.commitIsNull = \u63d0\u4ea4\u5167\u5bb9\u662f\u7a7a\u7684
-gb.commitMessageRenderer = \u63d0\u4ea4\u8a0a\u606f\u5448\u73fe\u65b9\u5f0f
-gb.commitMessageRendererDescription = \u63d0\u4ea4\u8a0a\u606f\u53ef\u4ee5\u4f7f\u7528\u6587\u5b57\u6216\u662f\u6a19\u8a18\u8a9e\u8a00(markup)\u5448\u73fe
-gb.commits = \u63d0\u4ea4
-gb.commitsInPatchsetN = \u88dc\u4e01 {0} \u7684\u63d0\u4ea4
-gb.commitsTo = {0} commits to
-gb.committed = \u5df2\u63d0\u4ea4
-gb.committer = \u78ba\u8a8d\u63d0\u4ea4\u8005
-gb.compare = \u6bd4\u5c0d
-gb.compareToMergeBase = \u6bd4\u5c0d\u5f8c,\u5408\u4f75\u5230\u4e3b\u8981\u5de5\u4f5c\u5340
-gb.compareToN = \u8207{0}\u9032\u884c\u6bd4\u5c0d
-gb.completeGravatarProfile = \u5b8c\u6210Gravator.com\u4e0a\u7684\u57fa\u672c\u8cc7\u6599\u8a2d\u5b9a
-gb.confirmPassword = \u78ba\u8a8d\u5bc6\u78bc
-gb.content = \u5167\u5bb9
-gb.copyToClipboard = \u8907\u88fd\u5230\u526a\u8cbc\u677f
-gb.couldNotCreateFederationProposal = \u7121\u6cd5\u5efa\u7acb\u4e32\u9023\u7684\u5408\u4f5c\u63d0\u6848
-gb.couldNotFindFederationProposal = \u641c\u5c0b\u4e0d\u5230\u8981\u6c42\u4e32\u9023\u7684\u63d0\u6848
-gb.couldNotFindFederationRegistration = \u627e\u4e0d\u5230\u4e32\u9023\u8a3b\u518a\u55ae
-gb.couldNotFindTag = \u627e\u4e0d\u5230\u6a19\u7c64{0}
-gb.countryCode = \u570b\u5bb6\u4ee3\u78bc
-gb.create = \u5efa\u7acb
-gb.createdBy = created by
-gb.createdNewBranch = \u5efa\u7acb\u65b0\u5206\u652f
-gb.createdNewPullRequest = created pull request
-gb.createdNewTag = \u5efa\u7acb\u65b0\u6a19\u7c64
-gb.createdThisTicket = \u5df2\u958b\u7acb\u7684\u4efb\u52d9\u55ae
-gb.createFirstTicket = \u6309\u6b64\u9996\u767c\u4efb\u52d9\u55ae
-gb.createPermission = {0} (push, ref creation)
-gb.createReadme = \u5efa\u7acbREADME\u6a94\u6848
-gb.customFields = custom fields
-gb.customFieldsDescription = custom fields available to Groovy hooks
-gb.dailyActivity = \u6bcf\u65e5\u6d3b\u52d5
-gb.dashboard = \u5100\u8868\u677f
-gb.date = \u65e5\u671f
-gb.default = \u9810\u8a2d
-gb.delete = \u522a\u9664
-gb.deletedBranch = deleted branch
-gb.deletedTag = \u522a\u9664\u6a19\u7c64
-gb.deleteMilestone = \u522a\u9664\u91cc\u7a0b\u7891"{0}"?
-gb.deletePermission = {0} (push, ref creation+deletion)
-gb.deleteRepository = \u522a\u9664\u7248\u672c\u5eab"{0}"?
-gb.deleteRepositoryDescription = \u7248\u672c\u5eab\u522a\u9664\u5c07\u7121\u6cd5\u9084\u539f
-gb.deleteRepositoryHeader = \u522a\u9664\u7248\u672c\u5eab
-gb.deleteUser = \u522a\u9664\u4f7f\u7528\u8005"{0}"?
-gb.deletion = \u522a\u9664
+gb.repository = \u7248\u672c\u5eab
+gb.owner = \u64c1\u6709\u8005
 gb.description = \u6982\u8ff0
+gb.lastChange = \u6700\u8fd1\u4fee\u6539
+gb.refs = \u6bd4\u5c0d
+gb.tag = \u6a19\u7c64
+gb.tags = \u6a19\u7c64
+gb.author = \u4f5c\u8005
+gb.committer = \u78ba\u8a8d\u8005
+gb.commit = \u63d0\u4ea4
+gb.age = \u6642\u9593
+gb.tree = \u76ee\u9304
+gb.parent = \u4e0a\u500b\u7248\u672c
+gb.url = URL
+gb.history = \u6b77\u53f2
+gb.raw = \u539f\u59cb
+gb.object = \u7269\u4ef6
+gb.ticketId = \u4efb\u52d9ID
+gb.ticketAssigned = \u5df2\u6307\u5b9a
+gb.ticketOpenDate = \u767c\u884c\u65e5
+gb.ticketComments = \u8a3b\u89e3
+gb.view = \u6aa2\u8996
+gb.local = \u672c\u5730\u7aef
+gb.remote = \u9060\u7aef
+gb.branches = \u5206\u652f
+gb.patch = \u4fee\u88dc\u6a94
+gb.diff = \u5dee\u7570
+gb.log = \u65e5\u8a8c
+gb.moreLogs = \u66f4\u591a\u63d0\u4ea4 ...
+gb.allTags = \u6240\u6709\u6a19\u7c64
+gb.allBranches = \u6240\u6709\u5206\u652f
+gb.summary = \u532f\u7e3d
+gb.ticket = \u4efb\u52d9
+gb.newRepository = \u5efa\u7acb\u7248\u672c\u5eab
+gb.newUser = \u5efa\u7acb\u4f7f\u7528\u8005
+gb.commitdiff = \u5dee\u7570
+gb.tickets = \u4efb\u52d9
+gb.pageFirst = \u7b2c\u4e00\u7b46
+gb.pagePrevious = \u4e0a\u4e00\u9801
+gb.pageNext = \u4e0b\u4e00\u9801
+gb.head = HEAD
+gb.blame = \u8a73\u67e5
+gb.login = \u767b\u5165
+gb.logout = \u767b\u51fa
+gb.username = \u4f7f\u7528\u8005\u540d\u7a31
+gb.password = \u5bc6\u78bc
+gb.tagger = \u6a19\u8a18\u8005
+gb.moreHistory = \u66f4\u591a\u6b77\u53f2\u7d00\u9304...
+gb.difftocurrent = \u6bd4\u5c0d\u5dee\u7570
+gb.search = \u641c\u5c0b
+gb.searchForAuthor = \u4f9d\u5be9\u6838\u8005\u641c\u5c0b\u63d0\u4ea4\u5167\u5bb9
+gb.searchForCommitter = \u4f9d\u63d0\u4ea4\u8005\u641c\u5c0b\u63d0\u4ea4\u5167\u5bb9
+gb.addition = \u589e\u52a0
+gb.modification = \u4fee\u6539
+gb.deletion = \u522a\u9664
+gb.rename = \u6539\u540d\u7a31
+gb.metrics = \u7d71\u8a08
+gb.stats = \u7d71\u8a08
+gb.markdown = markdown
+gb.changedFiles = \u5df2\u8b8a\u66f4\u904e\u7684\u6a94\u6848
+gb.filesAdded = \u65b0\u589e{0}\u500b\u6a94\u6848
+gb.filesModified = \u4fee\u6539{0}\u500b\u6a94\u6848
+gb.filesDeleted = \u522a\u9664{0}\u500b\u6a94\u6848
+gb.filesCopied = \u8907\u88fd{0}\u500b\u6a94\u6848
+gb.filesRenamed = \u4fee\u6539{0}\u500b\u6a94\u6848\u540d\u7a31
+gb.missingUsername = \u7121\u4f7f\u7528\u8005\u540d\u7a31
+gb.edit = \u7de8\u8f2f
+gb.searchTypeTooltip = \u9078\u64c7\u641c\u5c0b\u985e\u578b
+gb.searchTooltip = \u641c\u5c0b{0}
+gb.delete = \u522a\u9664
+gb.docs = \u6587\u4ef6
+gb.accessRestriction = \u9650\u5236\u5b58\u53d6
+gb.name = \u540d\u5b57
+gb.enableTickets = \u555f\u7528\u4efb\u52d9(Ticket)\u7cfb\u7d71
+gb.enableDocs = \u555f\u7528\u8aaa\u660e\u6587\u4ef6
+gb.save = \u5132\u5b58
+gb.showRemoteBranches = \u986f\u793a\u9060\u7aef\u5206\u652f
+gb.editUsers = \u4fee\u6539\u5e33\u865f
+gb.confirmPassword = \u78ba\u8a8d\u5bc6\u78bc
+gb.restrictedRepositories = \u53d7\u9650\u5236\u7684\u7248\u672c\u5eab
+gb.canAdmin = \u53ef\u7ba1\u7406
+gb.notRestricted = \u533f\u540d\u72c0\u614b\u53ef\u4ee5View, Clone\u8207Push
+gb.pushRestricted = \u6709\u6388\u6b0a\u624d\u80fd\u63a8\u9001(push)
+gb.cloneRestricted = \u6709\u6388\u6b0a\u624d\u80fd\u8907\u88fd(clone)\u8207\u63a8\u9001(push)
+gb.viewRestricted = \u6709\u6388\u6b0a\u624d\u80fd\u6aa2\u8996(view),\u8907\u88fd(clone), \u8207\u63a8\u9001(push)
+gb.useTicketsDescription = readonly, distributed Ticgit issues
+gb.useDocsDescription = \u8a08\u7b97\u7248\u672c\u5eab\u88e1\u9762\u7684Markdown\u6a94\u6848
+gb.showRemoteBranchesDescription = \u986f\u793a\u9060\u7aef\u5206\u652f(branches)
+gb.canAdminDescription = \u53ef\u7ba1\u7406Gitblit\u4f3a\u670d\u5668
+gb.permittedUsers = \u5141\u8a31\u7528\u6236
+gb.isFrozen = \u51cd\u7d50\u63a5\u6536
+gb.isFrozenDescription = \u7981\u6b62\u63a8\u9001(push)
+gb.zip = zip\u58d3\u7e2e\u6a94
+gb.showReadme = \u986f\u793areadme\u6587\u4ef6
+gb.showReadmeDescription = \u5728\u532f\u7e3d\u9801\u9762\u4e2d\u986f\u793a"readme"(markdown\u683c\u5f0f)
+gb.nameDescription = \u4f7f\u7528"/"\u505a\u70ba\u7248\u672c\u5eab\u7fa4\u7d44\u5206\u985e. \u5982: library/mycoolib.git
+gb.ownerDescription = \u64c1\u6709\u8005\u53ef\u4fee\u6539\u7248\u672c\u5eab\u8a2d\u5b9a\u503c
+gb.blob = \u5340\u584a
+gb.commitActivityTrend = \u63d0\u4ea4\u8da8\u52e2
+gb.commitActivityDOW = \u6bcf(\u65e5)\u9031\u63d0\u4ea4
+gb.commitActivityAuthors = \u63d0\u4ea4\u6d3b\u8e8d\u7387(\u4f7f\u7528\u8005)
+gb.feed = \u8cc7\u6599\u8a02\u95b1
+gb.cancel = \u53d6\u6d88
+gb.changePassword = \u4fee\u6539\u5bc6\u78bc
+gb.isFederated = \u5df2\u7d93federated
+gb.federateThis = \u8207\u6b64\u7248\u672c\u5eab federate
+gb.federateOrigin = federate the origin
+gb.excludeFromFederation = exclude from federation
+gb.excludeFromFederationDescription = \u7981\u6b62federated \u7684Gitblit\u4f3a\u670d\u5668
+gb.tokens = federation tokens
+gb.tokenAllDescription = \u6240\u6709\u7248\u672c\u5eab,\u4f7f\u7528\u8005\u8207\u8a2d\u5b9a
+gb.tokenUnrDescription = \u6240\u6709\u7248\u672c\u5eab\u8207\u4f7f\u7528\u8005
+gb.tokenJurDescription = \u6240\u6709\u7248\u672c\u5eab
+gb.federatedRepositoryDefinitions =  \u7248\u672c\u5eab\u5b9a\u7fa9
+gb.federatedUserDefinitions = \u4f7f\u7528\u8005\u5b9a\u7fa9
+gb.federatedSettingDefinitions = setting definitions
+gb.proposals = federation proposals
+gb.received = \u5df2\u63a5\u6536
+gb.type = \u985e\u578b
+gb.token = token
+gb.repositories = \u7248\u672c\u5eab
+gb.proposal = \u63d0\u6848
+gb.frequency = \u983b\u7387
+gb.folder = \u76ee\u9304
+gb.lastPull = \u4e0a\u6b21\u4e0b\u8f09(pull)
+gb.nextPull = \u4e0b\u4e00\u500b pull
+gb.inclusions = inclusions
+gb.exclusions = \u6392\u9664
+gb.registration = \u8a3b\u518a
+gb.registrations = federation registrations
+gb.sendProposal = \u63d0\u6848
+gb.status = \u72c0\u614b
+gb.origin = origin
+gb.headRef = \u9810\u8a2d\u5206\u652f(HEAD)
+gb.headRefDescription = \u9810\u8a2d\u5206\u652f\u5c07\u6703\u8907\u88fd\u4ee5\u53ca\u986f\u793a\u5230\u532f\u7e3d\u9801\u9762
+gb.federationStrategy = federation \u7b56\u7565
+gb.federationRegistration = federation registration
+gb.federationResults = federation pull results
+gb.federationSets = federation sets
+gb.message = \u8a0a\u606f
+gb.myUrlDescription = \u60a8Gitblit\u4f3a\u670d\u5668\u7684\u516c\u958bURL
 gb.destinationUrl = \u50b3\u9001
 gb.destinationUrlDescription = \u50b3\u9001Gitblit\u9023\u7d50\u5230\u4f60\u7684\u5c08\u6848(proposal)
-gb.diff = \u5dee\u7570
-gb.diffCopiedFile = File was copied from {0}
-gb.diffDeletedFile = \u6a94\u6848\u5df2\u522a\u9664
-gb.diffDeletedFileSkipped = (\u522a\u9664)
-gb.diffFileDiffTooLarge = \u6a94\u6848\u592a\u5927
-gb.diffNewFile = \u6bd4\u5c0d\u65b0\u6a94\u6848
-gb.diffRenamedFile = File was renamed from {0}
-gb.diffStat = \u65b0\u589e{0}\u5217\u8207\u522a\u9664{1}\u5217
-gb.difftocurrent = \u6bd4\u5c0d\u5dee\u7570
-gb.diffTruncated = Diff truncated after the above file
-gb.disableUser = \u505c\u7528\u5e33\u6236
-gb.disableUserDescription = \u8a72\u5e33\u6236\u7121\u6cd5\u4f7f\u7528
-gb.discussion = \u8a0e\u8ad6
-gb.displayName = \u986f\u793a\u7684\u540d\u7a31
-gb.displayNameDescription = \u5e0c\u671b\u986f\u793a\u7684\u540d\u7a31
-gb.docs = \u6a94\u6848\u5340
-gb.docsWelcome1 = \u4f60\u53ef\u4ee5\u4f7f\u7528\u6a94\u6848\u5340\u5efa\u7acb\u6587\u4ef6\u5eab\u7684\u6559\u5b78\u6a94\u6848
-gb.docsWelcome2 = \u63d0\u4ea4README.md \u6216 HOME.md\u5f8c,\u518d\u958b\u59cb\u65b0\u7684\u6587\u4ef6\u5eab
-gb.doesNotExistInTree = {0}\u4e26\u6c92\u6709\u5728\u76ee\u9304{1}\u88e1\u9762
-gb.download = \u4e0b\u8f09
-gb.downloading = \u4e0b\u8f09ing
-gb.due = due
-gb.duration = \u9031\u671f
-gb.duration.days = {0}\u5929
-gb.duration.months = {0}\u6708
-gb.duration.oneDay = 1\u5929
-gb.duration.oneMonth = 1\u6708
-gb.duration.oneYear = 1\u5e74
-gb.duration.years = {0}\u5e74
-gb.edit = \u7de8\u8f2f
-gb.editMilestone = \u4fee\u6539milestone
-gb.editTicket = \u4fee\u6539\u4efb\u52d9\u55ae
-gb.editUsers = \u4fee\u6539\u5e33\u865f
-gb.effective = \u6240\u6709\u6b0a\u9650
-gb.emailAddress = \u96fb\u5b50\u90f5\u4ef6
-gb.emailAddressDescription = \u7528\u4f86\u63a5\u6536\u901a\u77e5\u7684\u4e3b\u8981\u96fb\u5b50\u90f5\u4ef6
-gb.emailCertificateBundle = \u5bc4\u767c\u7528\u6236\u7aef\u8b49\u66f8
-gb.emailMeOnMyTicketChanges = \u6211\u7684\u4efb\u52d9\u55ae\u82e5\u6709\u8b8a\u66f4,\u8acb800\u91cc\u52a0\u6025(email)\u901a\u77e5\u6211
-gb.emailMeOnMyTicketChangesDescription  = \u6211\u8655\u7406\u904e\u7684\u4efb\u52d9\u55ae\u8acbemail\u901a\u77e5\u6211
-gb.empty = \u7a7a\u7684
-gb.emptyRepository = \u7a7a\u7684\u7248\u672c\u5eab
-gb.enableDocs = \u555f\u7528\u6a94\u6848\u5340
-gb.enableIncrementalPushTags = \u555f\u7528\u81ea\u52d5\u65b0\u589e\u6a19\u7c64\u529f\u80fd
-gb.enableTickets = \u555f\u7528\u4efb\u52d9\u55ae\u7cfb\u7d71
-gb.enhancementTickets = \u512a\u5316
-gb.enterKeystorePassword = \u8acb\u8f38\u5165Gitblit\u7684keystore\u5c08\u7528\u5bc6\u78bc
+gb.users = \u4f7f\u7528\u8005
+gb.federation = federation
 gb.error = \u932f\u8aa4
-gb.errorAdministrationDisabled = \u7ba1\u7406\u6b0a\u9650\u5df2\u53d6\u6d88
+gb.refresh = \u91cd\u6574
+gb.browse = \u700f\u89bd
+gb.clone = \u8907\u88fd(clone)
+gb.filter = \u7be9\u9078
+gb.create = \u5efa\u7acb
+gb.servers = \u4f3a\u670d\u5668
+gb.recent = \u6700\u8fd1
+gb.available = \u53ef\u7528
+gb.selected = \u9078\u5b9a
+gb.size = \u5bb9\u91cf
+gb.downloading = \u4e0b\u8f09\u4e2d
+gb.loading = \u8f09\u5165
+gb.starting = \u555f\u52d5\u4e2d
+gb.general = \u4e00\u822c
+gb.settings = \u8a2d\u5b9a
+gb.manage = \u7ba1\u7406
+gb.lastLogin = \u6700\u8fd1\u767b\u5165
+gb.skipSizeCalculation = \u7565\u904e\u5bb9\u91cf\u8a08\u7b97
+gb.skipSizeCalculationDescription = \u4e0d\u8a08\u7b97\u7248\u672c\u5eab\u5bb9\u91cf(\u52a0\u5feb\u7db2\u9801\u8f09\u5165\u901f\u5ea6)
+gb.skipSummaryMetrics = \u7565\u904e\u91cf\u5316\u532f\u7e3d
+gb.skipSummaryMetricsDescription = \u4e0d\u8981\u8a08\u7b97\u91cf\u5316\u4e26\u4e14\u986f\u793a\u5728\u532f\u7e3d\u9801\u9762\u4e0a(\u52a0\u5feb\u901f\u5ea6)
+gb.accessLevel = \u5b58\u53d6\u7b49\u7d1a
+gb.default = \u9810\u8a2d
+gb.setDefault = \u8a2d\u70ba\u9810\u8a2d\u503c
+gb.since = \u5f9e
+gb.bootDate = \u555f\u52d5\u65e5
+gb.servletContainer = servlet\u5bb9\u5668
+gb.heapMaximum = \u6700\u5927\u5806\u7a4d(heap)
+gb.heapAllocated = \u5df2\u4f7f\u7528\u5806\u7a4d(Heap)
+gb.heapUsed = \u5df2\u4f7f\u7528\u7684\u5806\u7a4d(heap)
+gb.free = \u91cb\u653e
+gb.version = \u7248\u672c
+gb.releaseDate = \u767c\u8868\u65e5
+gb.date = \u65e5\u671f
+gb.activity = \u6d3b\u52d5
+gb.subscribe = \u8a02\u95b1
+gb.branch = \u5206\u652f
+gb.maxHits = \u6700\u5927\u9ede\u64ca
+gb.recentActivity = \u6700\u8fd1\u6d3b\u8e8d\u72c0\u6cc1
+gb.recentActivityStats = \u904e\u53bb{0}\u5929,\u4e00\u5171\u6709{2}\u4eba\u505a\u4e86{1}\u4efd\u63d0\u4ea4
+gb.recentActivityNone = \u904e\u53bb{0}\u5929/\u7121
+gb.dailyActivity = \u6bcf\u65e5\u6d3b\u52d5
+gb.activeRepositories = \u6d3b\u8e8d\u7248\u672c\u5eab
+gb.activeAuthors = \u6d3b\u8e8d\u7528\u6236
+gb.commits = \u63d0\u4ea4
+gb.teams = \u5718\u968a
+gb.teamName = \u5718\u968a\u540d\u7a31
+gb.teamMembers = \u5718\u968a\u6210\u54e1
+gb.teamMemberships = \u5718\u968a\u6210\u54e1(memberships)
+gb.newTeam = \u5efa\u7acb\u5718\u968a
+gb.permittedTeams = permitted teams
+gb.emptyRepository = \u7a7a\u7684\u7248\u672c\u5eab
+gb.repositoryUrl = \u7248\u672c\u5eab url
+gb.mailingLists = \u90f5\u4ef6\u540d\u55ae
+gb.preReceiveScripts = pre-receive \u8173\u672c
+gb.postReceiveScripts = post-receive\u8173\u672c
+gb.hookScripts = hook\u7684\u8173\u672c
+gb.customFields = custom fields
+gb.customFieldsDescription = custom fields available to Groovy hooks
+gb.accessPermissions = \u5b58\u53d6\u6b0a\u9650
+gb.filters = \u7be9\u9078
+gb.generalDescription = \u4e00\u822c\u8a2d\u5b9a
+gb.accessPermissionsDescription = restrict access by users and teams
+gb.accessPermissionsForUserDescription = set team memberships or grant access to specific restricted repositories
+gb.accessPermissionsForTeamDescription = set team members and grant access to specific restricted repositories
+gb.federationRepositoryDescription = \u8207\u5176\u4ed6gitblit\u4f3a\u670d\u5668\u5206\u4eab\u4e00\u8d77\u4f7f\u7528\u9019\u500b\u7248\u672c\u5eab
+gb.hookScriptsDescription = \u7576\u63a8\u9001(push)\u81f3\u6b64Gitblit\u7248\u63a7\u4f3a\u670d\u5668\u6642, \u57f7\u884cGroovy\u8173\u672c
+gb.reset = \u6e05\u9664
+gb.pages = \u6587\u4ef6
+gb.workingCopy = \u66ab\u5b58\u8907\u672c
+gb.workingCopyWarning = \u8a72\u7248\u672c\u5eab\u4ecd\u6709\u66ab\u5b58\u8907\u672c,\u56e0\u6b64\u7121\u6cd5\u63a5\u53d7\u63a8\u9001(push)
+gb.query = \u67e5\u8a62
+gb.queryHelp = \u652f\u63f4\u6a19\u6e96\u67e5\u8a62\u8a9e\u6cd5.<p/><p/>\u8a73\u60c5\u8acb\u53c3\u8003 <a target\ = "_new" href\ = "http\://lucene.apache.org/core/old_versioned_docs/versions/3_5_0/queryparsersyntax.html">Lucene Query Parser Syntax</a>
+gb.queryResults = \u7d50\u679c {0} - {1} ({2} \u67e5\u8a62)
+gb.noHits = \u7121\u9ede\u64ca
+gb.authored = \u6388\u6b0a
+gb.committed = \u5df2\u63d0\u4ea4
+gb.indexedBranches = \u5206\u652f\u7d22\u5f15
+gb.indexedBranchesDescription = \u9078\u5b9a\u6b32\u57f7\u884cLucene\u7d22\u5f15\u529f\u80fd\u7684\u5206\u652f
+gb.noIndexedRepositoriesWarning = \u8ddf\u4f60\u76f8\u95dc\u7684\u7248\u672c\u5eab\u4e26\u6c92\u6709\u505aLucene\u7d22\u5f15
+gb.undefinedQueryWarning = \u672a\u8a2d\u5b9a\u67e5\u8a62\u689d\u4ef6
+gb.noSelectedRepositoriesWarning = \u8acb\u81f3\u5c11\u9078\u64c7\u4e00\u500b\u7248\u672c\u5eab
+gb.luceneDisabled = \u505c\u7528Lucene\u7d22\u5f15\u529f\u80fd
+gb.failedtoRead = \u8b80\u53d6\u5931\u6557
+gb.isNotValidFile = \u4e0d\u662f\u6709\u6548\u6a94\u6848
+gb.failedToReadMessage = Failed to read default message from {0}\!
+gb.passwordsDoNotMatch = \u5bc6\u78bc\u4e0d\u76f8\u7b26
+gb.passwordTooShort = \u5bc6\u78bc\u904e\u77ed, \u6700\u5c11{0}\u500b\u5b57\u5143
+gb.passwordChanged = \u5bc6\u78bc\u8b8a\u66f4\u6210\u529f
+gb.passwordChangeAborted = \u53d6\u6d88\u5bc6\u78bc\u8b8a\u66f4
+gb.pleaseSetRepositoryName = \u8acb\u8a2d\u5b9a\u7248\u672c\u5eab\u540d\u7a31
+gb.illegalLeadingSlash = \u7981\u6b62\u6839\u76ee\u9304(/)
+gb.illegalRelativeSlash = \u7981\u6b62\u76f8\u5c0d\u76ee\u9304(../)
+gb.illegalCharacterRepositoryName = \u7248\u672c\u5eab\u540d\u7a31\u6709\u4e0d\u5408\u6cd5\u7684\u5b57\u5143"{0}"
+gb.selectAccessRestriction = Please select access restriction\!
+gb.selectFederationStrategy = Please select federation strategy\!
+gb.pleaseSetTeamName = \u8acb\u8f38\u5165\u5718\u968a\u540d\u7a31
+gb.teamNameUnavailable = \u5718\u968a"{0}"\u4e0d\u5b58\u5728.
+gb.teamMustSpecifyRepository = \u5718\u968a\u6700\u5c11\u8981\u6307\u5b9a\u4e00\u500b\u7248\u672c\u5eab
+gb.teamCreated = \u5718\u968a"{0}"\u65b0\u589e\u6210\u529f.
+gb.pleaseSetUsername = \u8acb\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31
+gb.usernameUnavailable = \u4f7f\u7528\u8005\u540d\u7a31"{0}"\u4e0d\u53ef\u7528
+gb.combinedMd5Rename = Gitblit\u4f7f\u7528md5\u65b9\u5f0f\u5c07\u5bc6\u78bc\u7de8\u78bc(\u7121\u6cd5\u9084\u539f).\u4f60\u5fc5\u9808\u8f38\u5165\u65b0\u5bc6\u78bc.
+gb.userCreated = \u6210\u529f\u5efa\u7acb\u65b0\u4f7f\u7528\u8005"{0}"
+gb.couldNotFindFederationRegistration = \u627e\u4e0d\u5230federation registration!
+gb.failedToFindGravatarProfile = \u7121\u6cd5\u627e\u5230\u5e33\u865f{0}\u7684Gravator\u8cc7\u6599
+gb.branchStats = \u9019\u500b\u5206\u652f{2}\u6709{0}\u500b\u63d0\u4ea4\u4ee5\u53ca{1}\u500b\u6a19\u7c64
+gb.repositoryNotSpecified = \u672a\u6307\u5b9a\u7248\u672c\u5eab!
+gb.repositoryNotSpecifiedFor = \u7248\u672c\u5eab\u4e26\u6c92\u6709\u6307\u5b9a\u7d66 {0}\!
+gb.canNotLoadRepository = \u7121\u6cd5\u8f09\u5165\u7248\u672c\u5eab
+gb.commitIsNull = \u63d0\u4ea4\u5167\u5bb9\u662f\u7a7a\u7684
+gb.unauthorizedAccessForRepository = \u7248\u672c\u5eab\u672a\u6388\u6b0a\u5b58\u53d6
+gb.failedToFindCommit = \u5728{1}\u4e2d\u7121\u6cd5\u627e\u5230\u8a72 {0} \u63d0\u4ea4!
+gb.couldNotFindFederationProposal = \u641c\u5c0b\u4e0d\u5230federation proposal!
+gb.invalidUsernameOrPassword = \u932f\u8aa4\u7684\u4f7f\u7528\u8005\u540d\u7a31\u6216\u5bc6\u78bc!
+gb.OneProposalToReview = \u6709\u4e00\u500bfederation proposal \u7b49\u5f85\u5be9\u67e5
+gb.nFederationProposalsToReview = \u7e3d\u5171\u6709{0}\u500bfederation proposals\u7b49\u5f85\u5be9\u8996
+gb.couldNotFindTag = \u627e\u4e0d\u5230\u6a19\u7c64{0}
+gb.couldNotCreateFederationProposal = \u7121\u6cd5\u5efa\u7acbfederation proposals
+gb.pleaseSetGitblitUrl = \u8acb\u8f38\u5165Gitblit URL !
+gb.pleaseSetDestinationUrl = Please enter a destination url for your proposal\!
+gb.proposalReceived = Proposal successfully received by {0}.
+gb.noGitblitFound = Sorry, {0} could not find a Gitblit instance at {1}.
+gb.noProposals = \u62b1\u6b49, {0}\u6b64\u6642\u4e26\u4e0d\u662f\u53ef\u63a5\u53d7\u7684proposals.
+gb.noFederation = Sorry, {0} is not configured to federate with any Gitblit instances.
+gb.proposalFailed = Sorry, {0} did not receive any proposal data\!
+gb.proposalError = \u62b1\u6b49, {0} \u4efd\u5831\u544a\u767c\u751f\u9810\u671f\u5916\u7684\u932f\u8aa4!
+gb.failedToSendProposal = \u63d0\u6848\u767c\u9001\u5931\u6557\!
+gb.userServiceDoesNotPermitAddUser = {0}\u4e0d\u5141\u8a31\u65b0\u589e\u4f7f\u7528\u8005\u5e33\u865f
+gb.userServiceDoesNotPermitPasswordChanges = {0}\u4e0d\u5141\u8a31\u4fee\u6539\u5bc6\u78bc
+gb.displayName = \u986f\u793a\u7684\u540d\u7a31
+gb.emailAddress = \u96fb\u5b50\u90f5\u4ef6
 gb.errorAdminLoginRequired = \u767b\u5165\u9700\u6709\u7ba1\u7406\u6b0a\u9650
 gb.errorOnlyAdminMayCreateRepository = \u53ea\u6709\u7ba1\u7406\u8005\u80fd\u5efa\u7acb\u7248\u672c\u5eab
 gb.errorOnlyAdminOrOwnerMayEditRepository = \u53ea\u6709\u7ba1\u7406\u8005\u8207\u7248\u672c\u5eab\u64c1\u6709\u8005\u80fd\u4fee\u6539\u7248\u672c\u5eab\u5c6c\u6027
-gb.excludeFromActivity = exclude from activity page
-gb.excludeFromFederation = \u6392\u9664\u4e32\u9023
-gb.excludeFromFederationDescription = \u963b\u64cb\u5df2\u4e32\u9023\u7684Gitblit\u4f3a\u670d\u5668
-gb.excludePermission = {0} (\u6392\u9664)
-gb.exclusions = \u6392\u9664
-gb.expired = \u904e\u671f
-gb.expires = \u5230\u671f
-gb.expiring = \u5c07\u8981\u904e\u671f
-gb.export = \u532f\u51fa
-gb.extensions = \u64f4\u5145
-gb.externalPermissions = {0} access permissions are externally maintained
-gb.failedToFindAccount = \u7121\u6cd5\u641c\u5c0b\u5230\u5e33\u865f"{0}"
-gb.failedToFindCommit = Failed to find commit "{0}" in {1}\!
-gb.failedToFindGravatarProfile = \u7121\u6cd5\u627e\u5230\u5e33\u865f{0}\u7684Gravator\u8cc7\u6599
-gb.failedtoRead = \u8b80\u53d6\u5931\u6557
-gb.failedToReadMessage = Failed to read default message from {0}\!
-gb.failedToSendProposal = \u63d0\u6848\u767c\u9001\u5931\u6557\!
-gb.failedToUpdateUser = \u7121\u6cd5\u66f4\u65b0\u4f7f\u7528\u8005\u5e33\u865f
-gb.federatedRepositoryDefinitions =  \u7248\u672c\u5eab\u5b9a\u7fa9
-gb.federatedSettingDefinitions = setting definitions
-gb.federatedUserDefinitions = user definitions
-gb.federateOrigin = federate the origin
-gb.federateThis = \u8207\u672c\u6587\u4ef6\u5eab\u4e32\u9023
-gb.federation = \u4e32\u9023
-gb.federationRegistration = federation registration
-gb.federationRepositoryDescription = \u8207\u5176\u4ed6gitblit\u4f3a\u670d\u5668\u5206\u4eab\u4e00\u8d77\u4f7f\u7528\u9019\u500b\u7248\u672c\u5eab
-gb.federationResults = federation pull results
-gb.federationSets = \u4e32\u9023\u7d44\u5408
-gb.federationSetsDescription = \u6b64\u6587\u4ef6\u5eab\u5c07\u5305\u542b\u65bc\u6307\u5b9a\u7684\u4e32\u9023\u7fa4\u7d44(federation sets)
-gb.federationStrategy = \u4e32\u9023\u7b56\u7565
-gb.federationStrategyDescription = \u63a7\u5236\u5982\u4f55\u5c07\u6587\u4ef6\u5eab\u8207\u5176\u4ed6Gitblit\u7248\u63a7\u4f3a\u670d\u5668\u4e32\u9023
-gb.feed = \u8cc7\u6599\u8a02\u95b1
-gb.filesAdded = \u65b0\u589e{0}\u500b\u6a94\u6848
-gb.filesCopied = \u8907\u88fd{0}\u500b\u6a94\u6848
-gb.filesDeleted = \u522a\u9664{0}\u500b\u6a94\u6848
-gb.filesModified = \u4fee\u6539{0}\u500b\u6a94\u6848
-gb.filesRenamed = \u4fee\u6539{0}\u500b\u6a94\u6848\u540d\u7a31
-gb.filter = \u689d\u4ef6\u904e\u6ffe
-gb.filters = \u67e5\u8a62\u689d\u4ef6
-gb.findSomeRepositories = \u641c\u5c0b\u6587\u4ef6\u5eab
-gb.folder = \u76ee\u9304
+gb.errorAdministrationDisabled = \u7ba1\u7406\u6b0a\u9650\u5df2\u53d6\u6d88
+gb.lastNDays = \u6700\u8fd1{0}\u5929
+gb.completeGravatarProfile = \u5b8c\u6210Gravator.com\u4e0a\u7684\u57fa\u672c\u8cc7\u6599\u8a2d\u5b9a
+gb.none = \u7121
+gb.line = \u884c
+gb.content = \u5167\u5bb9
+gb.empty = \u7a7a\u7684
+gb.inherited = \u7e7c\u627f
+gb.deleteRepository = \u522a\u9664\u7248\u672c\u5eab"{0}"?
+gb.repositoryDeleted = \u7248\u672c\u5eab"{0}"\u5df2\u522a\u9664
+gb.repositoryDeleteFailed = \u522a\u9664\u7248\u672c\u5eab"{0}"\u5931\u6557!
+gb.deleteUser = \u522a\u9664\u4f7f\u7528\u8005"{0}"?
+gb.userDeleted = \u4f7f\u7528\u8005"{0}"\u5df2\u522a\u9664
+gb.userDeleteFailed = \u4f7f\u7528\u8005"{0}"\u522a\u9664\u5931\u6557
+gb.time.justNow = \u525b\u525b
+gb.time.today = \u4eca\u5929
+gb.time.yesterday = \u6628\u5929
+gb.time.minsAgo = {0}\u5206\u9418\u524d
+gb.time.hoursAgo = {0}\u5c0f\u6642\u524d
+gb.time.daysAgo = {0}\u5929\u524d
+gb.time.weeksAgo = {0}\u5468\u524d
+gb.time.monthsAgo = {0}\u6708\u524d
+gb.time.oneYearAgo = 1\u5e74\u524d
+gb.time.yearsAgo = {0}\u5e74\u524d
+gb.duration.oneDay = 1\u5929
+gb.duration.days = {0}\u5929
+gb.duration.oneMonth = 1\u6708
+gb.duration.months = {0}\u6708
+gb.duration.oneYear = 1\u5e74
+gb.duration.years = {0}\u5e74
+gb.authorizationControl = \u6388\u6b0a\u7ba1\u63a7
+gb.allowAuthenticatedDescription = \u6279\u51c6 RW+ \u6b0a\u9650\u7d66\u4e88\u5c08\u6848\u6210\u54e1
+gb.allowNamedDescription = grant fine-grained permissions to named users or teams
+gb.markdownFailure = \u89e3\u6790Markdown\u5931\u6557
+gb.clearCache = \u6e05\u9664\u5feb\u53d6
+gb.projects = \u7fa4\u7d44
+gb.project = \u7fa4\u7d44
+gb.allProjects = \u5168\u90e8\u7fa4\u7d44
+gb.copyToClipboard = \u8907\u88fd\u5230\u526a\u8cbc\u677f
 gb.fork = \u5efa\u7acb\u5206\u652f(fork)
-gb.forkedFrom = forked from
-gb.forkInProgress = fork in progress
-gb.forkNotAuthorized = \u5f88\u62b1\u6b49, \u4f60\u7121\u5efa\u7acb\u6587\u4ef6\u5eab{0}\u5206\u652f(fork)\u7684\u6b0a\u9650
-gb.forkRepository = \u7248\u672c\u5eab{0}\u5efa\u7acb\u5206\u652f(fork)?
 gb.forks = \u5206\u652f(forks)
+gb.forkRepository = \u7248\u672c\u5eab{0}\u5efa\u7acb\u5206\u652f(fork)?
+gb.repositoryForked = \u7248\u672c\u5eab{0}\u5df2\u7d93\u5efa\u7acb\u5206\u652f(fork)
+gb.repositoryForkFailed = \u5efa\u7acb\u5206\u652f(fork)\u5931\u6557
+gb.personalRepositories = \u500b\u4eba\u7248\u672c\u5eab
+gb.allowForks = \u5141\u8a31\u5efa\u7acb\u5206\u652f(forks)
+gb.allowForksDescription = \u5141\u8a31\u5df2\u6388\u6b0a\u7684\u4f7f\u7528\u8005\u5f9e\u7248\u672c\u5eab\u5efa\u7acb\u5206\u652f(fork)
+gb.forkedFrom = \u6e90\u81ea\u65bc
+gb.canFork = \u53ef\u5efa\u7acb\u5206\u652f(fork)
+gb.canForkDescription = \u53ef\u4ee5\u5efa\u7acb\u7248\u672c\u5eab\u5206\u652f(fork),\u4e26\u4e14\u8907\u88fd\u5230\u79c1\u4eba\u7248\u672c\u5eab\u4e2d
+gb.myFork = \u6aa2\u8996\u6211\u5efa\u7acb\u7684\u5206\u652f(fork)
 gb.forksProhibited = \u7981\u6b62\u5efa\u7acb\u5206\u652f(forks)
-gb.forksProhibitedWarning = \u672c\u6587\u4ef6\u5eab\u7981\u6b62\u5206\u652f(fork)
-gb.free = \u91cb\u653e
-gb.frequency = \u983b\u7387
-gb.from = from
-gb.garbageCollection = \u56de\u6536\u7cfb\u7d71\u8cc7\u6e90
-gb.garbageCollectionDescription = \u7cfb\u7d71\u8cc7\u6e90\u56de\u6536\u529f\u80fd\u5c07\u6703\u6574\u9813\u9b06\u6563\u7528\u6236\u7aef\u63a8\u9001(push)\u7684\u7269\u4ef6, \u4e5f\u6703\u79fb\u9664\u6587\u4ef6\u5eab\u4e0a\u7121\u7528\u7684\u7269\u4ef6
-gb.gc = \u7cfb\u7d71\u8cc7\u6e90\u56de\u6536\u5668
+gb.forksProhibitedWarning = \u672c\u7248\u672c\u5eab\u7981\u6b62\u5206\u652f(fork)
+gb.noForks = {0}\u6c92\u6709\u5206\u652f(fork)
+gb.forkNotAuthorized = \u5f88\u62b1\u6b49, \u4f60\u7121\u5efa\u7acb\u7248\u672c\u5eab{0}\u5206\u652f(fork)\u7684\u6b0a\u9650
+gb.forkInProgress = \u6b63\u5728\u8907\u88fd\u4e2d(fork)
+gb.preparingFork = \u6b63\u5728\u6e96\u5099\u8907\u88fd\u4e2d(fork)...
+gb.isFork = \u662f\u5206\u652f\u985e\u578b(fork)
+gb.canCreate = \u53ef\u5efa\u7acb
+gb.canCreateDescription = \u80fd\u5920\u5efa\u7acb\u500b\u4eba\u7248\u672c\u5eab
+gb.illegalPersonalRepositoryLocation = \u4f60\u500b\u4eba\u7248\u672c\u5eab\u5fc5\u9808\u653e\u5728"{0}"
+gb.verifyCommitter = \u63d0\u4ea4\u8005\u9700\u9a57\u8b49
+gb.verifyCommitterDescription = \u9700\u8981\u63d0\u4ea4\u8005\u7b26\u5408\u63a8\u9001\u5e33\u865f
+gb.verifyCommitterNote = \u6240\u6709\u5408\u4f75\u52d5\u4f5c\u7686\u9808\u5f37\u5236\u4f7f\u7528"--no-ff"\u53c3\u6578
+gb.repositoryPermissions = \u7248\u672c\u5eab\u6b0a\u9650
+gb.userPermissions = \u4f7f\u7528\u8005\u6b0a\u9650
+gb.teamPermissions = \u5718\u968a\u6b0a\u9650
+gb.add = \u65b0\u589e
+gb.noPermission = \u522a\u9664\u9019\u500b\u6b0a\u9650
+gb.excludePermission = {0} \u6392\u9664(exclude)
+gb.viewPermission = {0} \u6aa2\u8996(view)
+gb.clonePermission = {0} \u8907\u88fd(clone)
+gb.pushPermission = {0} \u63a8\u9001(push)
+gb.createPermission = {0} (push, ref creation)
+gb.deletePermission = {0} (push, ref creation+deletion)
+gb.rewindPermission = {0} (push, ref creation+deletion+rewind)
+gb.permission = \u6b0a\u9650
+gb.regexPermission = \u5df2\u7d93\u4f7f\u7528\u6b63\u898f\u8868\u793a\u5f0f(regular expression)"{0}" \u8a2d\u5b9a\u6b0a\u9650\u5b8c\u7562
+gb.accessDenied = \u62d2\u7d55\u5b58\u53d6
+gb.busyCollectingGarbage = \u62b1\u6b49,Gitblit\u6b63\u5728\u56de\u6536\u7cfb\u7d71\u8cc7\u6e90\u4e2d:{0}
 gb.gcPeriod = \u7cfb\u7d71\u8cc7\u6e90\u56de\u6536\u968e\u6bb5
 gb.gcPeriodDescription = \u56de\u6536\u9031\u671f
 gb.gcThreshold = GC \u57fa\u6578(threshold)
 gb.gcThresholdDescription = \u89f8\u767c\u7cfb\u7d71\u8cc7\u6e90\u56de\u6536\u7684\u6700\u5c0f\u7269\u4ef6\u5bb9\u91cf
-gb.general = \u4e00\u822c
-gb.generalDescription = \u4e00\u822c\u8a2d\u5b9a
-gb.hasNotReviewed = \u5c1a\u672a\u6aa2\u6838\u904e
-gb.head = HEAD
-gb.headRef = \u9810\u8a2d\u5206\u652f(HEAD)
-gb.headRefDescription = \u9810\u8a2d\u5206\u652f\u5c07\u6703\u8907\u88fd\u4ee5\u53ca\u986f\u793a\u5230\u532f\u7e3d\u9801\u9762
-gb.heapAllocated = \u5df2\u4f7f\u7528\u5806\u7a4d(Heap)
-gb.heapMaximum = \u6700\u5927\u5806\u7a4d(heap)
-gb.heapUsed = \u5df2\u4f7f\u7528\u7684\u5806\u7a4d(heap)
-gb.history = \u6b77\u7a0b
-gb.home = \u9996\u9801
-gb.hookScripts = hook\u7684\u8173\u672c
-gb.hookScriptsDescription = \u7576\u63a8\u9001(push)\u81f3\u6b64Gitblit\u7248\u63a7\u4f3a\u670d\u5668\u6642, \u57f7\u884cGroovy\u8173\u672c
+gb.ownerPermission = \u7248\u672c\u5eab\u64c1\u6709\u8005
+gb.administrator = \u7ba1\u7406\u54e1
+gb.administratorPermission = Gitblit \u7ba1\u7406\u54e1
+gb.team = \u5718\u968a
+gb.teamPermission = "{0}" \u5718\u968a\u6210\u54e1\u7684\u6b0a\u9650
+gb.missing = \u5931\u8aa4!
+gb.missingPermission = \u8a72\u6b0a\u9650\u7121\u6cd5\u5c0d\u61c9\u5230\u7248\u672c\u5eab!
+gb.mutable = \u52d5\u614b\u7d66\u4e88
+gb.specified = \u6307\u5b9a\u7d66\u4e88(\u542b\u7cfb\u7d71\u9810\u8a2d)
+gb.effective = \u6240\u6709\u6b0a\u9650
+gb.organizationalUnit = \u7d44\u7e54\u55ae\u4f4d
+gb.organization = \u7d44\u7e54
+gb.locality = \u4f4d\u7f6e
+gb.stateProvince = \u5dde\u6216\u7701
+gb.countryCode = \u570b\u5bb6\u4ee3\u78bc
+gb.properties = \u5c6c\u6027
+gb.issued = \u767c\u51fa
+gb.expires = \u5230\u671f
+gb.expired = \u904e\u671f
+gb.expiring = \u5c07\u8981\u904e\u671f
+gb.revoked = \u5df2\u64a4\u92b7
+gb.serialNumber = \u5e8f\u865f
+gb.certificates = \u8b49\u66f8
+gb.newCertificate = \u5efa\u7acb\u8b49\u66f8
+gb.revokeCertificate = \u64a4\u56de\u8b49\u66f8
+gb.sendEmail = \u767cemail
+gb.passwordHint = \u5bc6\u78bc\u63d0\u793a
+gb.ok = ok
+gb.invalidExpirationDate = \u4e0d\u6b63\u78ba\u7684\u5230\u671f\u65e5
+gb.passwordHintRequired = \u5bc6\u78bc\u63d0\u793a(\u5fc5\u8981)
+gb.viewCertificate = \u6aa2\u8996\u8b49\u66f8
+gb.subject = \u6a19\u984c
+gb.issuer = \u767c\u884c\u8005
+gb.validFrom = \u6709\u6548\u671f\u5f9e
+gb.validUntil = \u6709\u6548\u671f\u81f3
+gb.publicKey = \u516c\u958b\u91d1\u9470
+gb.signatureAlgorithm = \u7c3d\u7ae0\u6f14\u7b97\u6cd5
+gb.sha1FingerPrint = SHA-1 Fingerprint
+gb.md5FingerPrint = MD5 Fingerprint
+gb.reason = \u539f\u56e0
+gb.revokeCertificateReason = \u8acb\u8f38\u5165\u64a4\u56de\u8b49\u66f8\u7406\u7531
+gb.unspecified = \u672a\u6307\u5b9a
+gb.keyCompromise = \u91d1\u9470\u5bc6\u78bc\u5916\u6d29
+gb.caCompromise = CA compromise
+gb.affiliationChanged = affiliation changed
+gb.superseded = \u5df2\u88ab\u66ff\u4ee3
+gb.cessationOfOperation = cessation of operation
+gb.privilegeWithdrawn = \u53d6\u6d88\u6b0a\u9650
+gb.time.inMinutes = {0}\u5206\u9418\u5167
+gb.time.inHours = {0}\u5c0f\u6642\u5167
+gb.time.inDays = {0}\u5929\u5167
 gb.hostname = \u4e3b\u6a5f\u540d\u7a31
 gb.hostnameRequired = \u8acb\u8f38\u5165\u4e3b\u6a5f\u540d\u7a31
-gb.ignore_whitespace =\u5ffd\u7565\u7a7a\u767d
-gb.illegalCharacterRepositoryName = \u7248\u672c\u5eab\u540d\u7a31\u6709\u4e0d\u5408\u6cd5\u7684\u5b57\u5143"{0}"
-gb.illegalLeadingSlash = \u7981\u6b62\u6839\u76ee\u9304(/)
-gb.illegalPersonalRepositoryLocation = \u4f60\u79c1\u4eba\u7248\u672c\u5eab\u5fc5\u9808\u653e\u5728"{0}"
-gb.illegalRelativeSlash = \u7981\u6b62\u76f8\u5c0d\u76ee\u9304(../)
-gb.imgdiffSubtract = Subtract (black = identical)
-gb.in = in
-gb.inclusions = inclusions
-gb.incrementalPushTagMessage = \u7576[{0}]\u5206\u652f\u63a8\u9001\u5f8c,\u81ea\u52d5\u7d66\u4e88\u6a19\u7c64\u865f.
-gb.indexedBranches = \u5206\u652f\u7d22\u5f15
-gb.indexedBranchesDescription = \u9078\u5b9a\u6b32\u57f7\u884cLucene\u7d22\u5f15\u529f\u80fd\u7684\u5206\u652f
-gb.inherited = \u7e7c\u627f
-gb.initialCommit = \u521d\u6b21\u63d0\u4ea4
-gb.initialCommitDescription = \u4ee5\u4e0b\u6b65\u9a5f\u5c07\u6703\u8b93\u4f60\u99ac\u4e0a\u57f7\u884c<code>git clone</code>.\u5982\u679c\u4f60\u672c\u6a5f\u5df2\u6709\u6b64\u6587\u4ef6\u5eab\u4e14\u57f7\u884c\u904e<code>git init</code>,\u8acb\u8df3\u904e\u6b64\u6b65\u9a5f.
-gb.initWithGitignore = \u5305\u542b .gitignore \u6a94\u6848
-gb.initWithGitignoreDescription = \u65b0\u589e\u4e00\u500b\u8a2d\u5b9a\u6a94\u7528\u4f86\u6307\u5b9a\u54ea\u4e9b\u6a94\u6848\u6216\u76ee\u9304\u9700\u8981\u5ffd\u7565
-gb.initWithReadme = \u5305\u542bREADME\u6587\u4ef6
-gb.initWithReadmeDescription = \u6587\u4ef6\u5eab\u5c07\u7522\u751f\u7c21\u55aeREADME\u6587\u4ef6
-gb.invalidExpirationDate = \u4e0d\u6b63\u78ba\u7684\u5230\u671f\u65e5
-gb.invalidUsernameOrPassword = \u932f\u8aa4\u7684\u4f7f\u7528\u8005\u540d\u7a31\u6216\u5bc6\u78bc!
-gb.isFederated = \u5df2\u7d93\u4e32\u9023
-gb.isFork = \u662f\u5206\u652f\u985e\u578b(fork)
-gb.isFrozen = \u51cd\u7d50\u63a5\u6536
-gb.isFrozenDescription = \u7981\u6b62\u63a8\u9001(push)
-gb.isMirror = \u8a72\u6587\u4ef6\u5eab\u70ba\u93e1\u50cf(mirror)
-gb.isNotValidFile = \u4e0d\u662f\u6b63\u5e38\u6a94\u6848
-gb.isSparkleshared = \u8a72\u6587\u4ef6\u5eab\u5df2\u70baSparkleshared (http://sparkleshare.org)
-gb.issued = \u767c\u51fa
-gb.issuer = issuer
+gb.newSSLCertificate = \u65b0\u7684\u4f3a\u670d\u5668SSL\u8b49\u66f8
+gb.newCertificateDefaults = \u65b0\u8b49\u66f8\u9810\u8a2d\u503c
+gb.duration = \u9031\u671f
+gb.certificateRevoked = \u8b49\u66f8{0,number,0} \u5df2\u7d93\u88ab\u53d6\u6d88
+gb.clientCertificateGenerated = \u6210\u529f\u7522\u751f{0}\u7684\u65b0\u8b49\u66f8
+gb.sslCertificateGenerated = \u6210\u529f\u7522\u751f\u7d66{0}\u7684\u670d\u5668SSL\u8b49\u66f8
+gb.newClientCertificateMessage = \u6ce8\u610f:\n'password'\u5bc6\u78bc\u4e26\u4e0d\u662f\u4f7f\u7528\u8005\u5bc6\u78bc, \u800c\u662f\u7528\u4f86\u4fdd\u8b77\u4f7f\u7528\u8005\u500b\u4eba\u7684keystore.\u8a72\u5bc6\u78bc\u4e26\u4e0d\u6703\u5132\u5b58,  \u56e0\u6b64\u5fc5\u9808\u8a2d\u5b9a\u63d0\u793a(hint), \u8a72\u63d0\u793a\u5c07\u6703\u5beb\u5728\u4f7f\u7528\u8005\u7684README\u6587\u4ef6\u88e1\u9762.
+gb.certificate = \u8b49\u66f8
+gb.emailCertificateBundle = \u5bc4\u767c\u7528\u6236\u7aef\u8b49\u66f8
+gb.pleaseGenerateClientCertificate = \u8acb\u7522\u751f\u7d66{0}\u4f7f\u7528\u7684\u7528\u6236\u7aef\u8b49\u66f8
+gb.clientCertificateBundleSent = {0}\u7684\u7528\u6236\u8b49\u66f8\u5df2\u5bc4\u767c
+gb.enterKeystorePassword = \u8acb\u8f38\u5165Gitblit\u7684keystore\u5c08\u7528\u5bc6\u78bc
+gb.warning = \u8b66\u544a
 gb.jceWarning = Your Java Runtime Environment does not have the "JCE Unlimited Strength Jurisdiction Policy" files.\nThis will limit the length of passwords you may use to encrypt your keystores to 7 characters.\nThese policy files are an optional download from Oracle.\n\nWould you like to continue and generate the certificate infrastructure anyway?\n\nAnswering No will direct your browser to Oracle's download page so that you may download the policy files.
-gb.key = \u91d1\u9470
-gb.keyCompromise = \u91d1\u9470\u5bc6\u78bc\u5916\u6d29
-gb.labels = \u6a19\u8a18
-gb.languagePreference = \u5e38\u7528\u8a9e\u8a00
-gb.languagePreferenceDescription = \u9078\u64c7\u4f60\u60f3\u8981\u7684Gitblit\u7ffb\u8b6f
-gb.lastChange = \u6700\u8fd1\u4fee\u6539
-gb.lastLogin = \u6700\u8fd1\u767b\u5165
-gb.lastNDays = \u6700\u8fd1{0}\u5929
-gb.lastPull = \u4e0a\u6b21\u4e0b\u8f09(pull)
-gb.leaveComment = \u7559\u4e0b\u8a3b\u89e3
-gb.line = \u884c
-gb.loading = \u8f09\u5165
-gb.local = \u672c\u5730\u7aef
-gb.locality = \u4f4d\u7f6e
-gb.log = \u65e5\u8a8c
-gb.login = \u767b\u5165
-gb.logout = \u767b\u51fa
-gb.looksGood = \u770b\u8d77\u4f86\u5f88\u597d
-gb.luceneDisabled = \u505c\u7528Lucene\u7d22\u5f15\u529f\u80fd
-gb.mailingLists = \u90f5\u4ef6\u540d\u55ae
-gb.maintenanceTickets = \u7dad\u8b77
-gb.manage = \u7ba1\u7406
-gb.manual = \u81ea\u884c\u8f38\u5165
-gb.markdown = markdown
-gb.markdownFailure = \u89e3\u6790Markdown\u5931\u6557
 gb.maxActivityCommits = \u6700\u5927\u63d0\u4ea4\u6d3b\u8e8d\u7387
 gb.maxActivityCommitsDescription = \u6700\u5927\u63d0\u4ea4\u6d3b\u8e8d\u6578\u91cf
-gb.maxHits = \u6700\u5927\u9ede\u64ca
-gb.md5FingerPrint = MD5 Fingerprint
-gb.mentions = \u63d0\u5230
-gb.mentionsMeTickets = \u63d0\u5230\u4f60
-gb.merge = \u5408\u4f75
-gb.mergeBase = \u57fa\u672c\u5408\u4f75
-gb.merged = \u5df2\u5408\u4f75
-gb.mergedPatchset = \u5c07\u88dc\u4e01\u5408\u4f75
-gb.mergedPullRequest = \u5408\u4f75\u63a8\u9001\u8981\u6c42
-gb.mergeSha = mergeSha
-gb.mergeStep1 = Check out a new branch to review the changes \u2014 run this from your project directory
-gb.mergeStep2 = Bring in the proposed changes and review
-gb.mergeStep3 = \u5c07\u63d0\u6848\u4fee\u6539\u5167\u5bb9\u5408\u4f75\u5230\u4f3a\u670d\u5668\u4e0a
-gb.mergeTo = \u5408\u4f75\u5230
-gb.mergeToDescription = \u9810\u8a2d\u5c07\u6587\u4ef6\u76f8\u95dc\u88dc\u4e01\u5305\u8207\u6307\u5b9a\u5206\u652f(branch)\u5408\u4f75
-gb.mergingViaCommandLine = \u7d93\u7531\u6307\u4ee4\u57f7\u884c\u5408\u4f75
-gb.mergingViaCommandLineNote = \u5982\u679c\u4f60\u4e0d\u60f3\u8981\u4f7f\u7528\u81ea\u52d5\u5408\u4f75\u529f\u80fd,\u6216\u662f\u6309\u4e0b\u5408\u4f75\u6309\u9215, \u4f60\u53ef\u4ee5\u4e0b\u6307\u4ee4\u624b\u52d5\u5408\u4f75
-gb.message = \u8a0a\u606f
-gb.metricAuthorExclusions = \u91cf\u5316\u7d71\u8a08\u6642\u6392\u9664\u6d3b\u8e8d\u5e33\u6236
-gb.metrics = \u91cf\u5316\u7d71\u8a08
-gb.milestone = \u91cc\u7a0b\u7891
-gb.milestoneDeleteFailed = \u522a\u9664\u91cc\u7a0b\u7891"{0}"\u5931\u6557
-gb.milestoneProgress = {0}\u958b\u555f,{1}\u7d50\u675f
-gb.milestones = \u91cc\u7a0b\u7891
-gb.mirrorOf = {0}\u7684\u93e1\u50cf
-gb.mirrorWarning = \u8a72\u6587\u4ef6\u5eab\u5c6c\u65bc\u93e1\u50cf, \u4e0d\u80fd\u5920\u63a5\u6536\u63a8\u9001(push)
-gb.miscellaneous = \u5176\u4ed6
-gb.missing = \u5931\u8aa4!
-gb.missingIntegrationBranchMore = \u76ee\u6a19\u5206\u652f\u4e0d\u5728\u6b64\u7248\u672c\u5eab
-gb.missingPermission = the repository for this permission is missing\!
-gb.missingUsername = \u7f3a\u5c11\u4f7f\u7528\u8005\u540d\u7a31
-gb.modification = \u4fee\u6539
-gb.monthlyActivity = \u6708\u6d3b\u52d5
-gb.moreChanges = \u6240\u6709\u8b8a\u66f4...
-gb.moreHistory = \u66f4\u591a\u6b77\u53f2\u7d00\u9304...
-gb.moreLogs = \u66f4\u591a\u63d0\u4ea4 ...
-gb.mutable = \u52d5\u614b\u7d66\u4e88
-gb.myDashboard = \u6211\u7684\u5100\u8868\u677f
-gb.myFork = \u6aa2\u8996\u6211\u5efa\u7acb\u7684\u5206\u652f(fork)
-gb.myProfile = \u6211\u7684\u57fa\u672c\u8cc7\u6599
-gb.myRepositories = \u6211\u7684\u7248\u672c\u5eab
-gb.myTickets = \u6211\u7684\u4efb\u52d9\u55ae
-gb.myUrlDescription = \u4f60Gitblit\u4f3a\u670d\u5668\u7684\u516c\u958bURL
-gb.name = \u540d\u5b57
-gb.nameDescription = \u4f7f\u7528"/"\u505a\u70ba\u6587\u4ef6\u5eab\u7fa4\u7d44\u5206\u985e. \u5982: library/mycoolib.git
-gb.namedPushPolicy = Restrict Push (Named)
-gb.namedPushPolicyDescription = \u4efb\u4f55\u4eba\u7686\u53ef\u6aa2\u8996\u8207\u8907\u88fd(clone)\u6587\u4ef6\u5eab. \u4f60\u53ef\u53e6\u5916\u6307\u5b9a\u8ab0\u80fd\u5920\u6709\u63a8\u9001\u529f\u80fd(push)
-gb.nAttachments = {0}\u500b\u9644\u4ef6
-gb.nClosedTickets = {0}\u9805\u7d50\u675f
-gb.nComments = {0}\u500b\u8a3b\u89e3
-gb.nCommits = {0}\u4efd\u63d0\u4ea4
-gb.needsImprovement = \u9700\u8981\u512a\u5316
-gb.new = \u5efa\u7acb
-gb.newCertificate = \u5efa\u7acb\u8b49\u66f8
-gb.newCertificateDefaults = \u65b0\u8b49\u66f8\u9810\u8a2d\u503c
-gb.newClientCertificateMessage = \u6ce8\u610f:\n'password'\u5bc6\u78bc\u4e26\u4e0d\u662f\u4f7f\u7528\u8005\u5bc6\u78bc, \u800c\u662f\u7528\u4f86\u4fdd\u8b77\u4f7f\u7528\u8005\u500b\u4eba\u7684keystore.\u8a72\u5bc6\u78bc\u4e26\u4e0d\u6703\u5132\u5b58,  \u56e0\u6b64\u5fc5\u9808\u8a2d\u5b9a\u63d0\u793a(hint), \u8a72\u63d0\u793a\u5c07\u6703\u5beb\u5728\u4f7f\u7528\u8005\u7684README\u6587\u4ef6\u88e1\u9762.
-gb.newMilestone = \u5efa\u7acb\u91cc\u7a0b\u7891
-gb.newRepository = \u5efa\u7acb\u7248\u672c\u5eab
-gb.newSSLCertificate = \u65b0\u7684\u4f3a\u670d\u5668SSL\u8b49\u66f8
-gb.newTeam = \u5efa\u7acb\u5718\u968a
-gb.newTicket = \u65b0\u589e\u4efb\u52d9\u55ae
-gb.newUser = \u5efa\u7acb\u4f7f\u7528\u8005
-gb.nextPull = next pull
-gb.nFederationProposalsToReview = \u7e3d\u5171\u6709{0}\u500b\u4e32\u9023\u8a08\u756b\u7b49\u5f85\u5be9\u8996
-gb.nMoreCommits = \u9084\u6709{0}\u4efd\u63d0\u4ea4 \u00bb
-gb.noActivity = \u904e\u53bb{0}\u5929\u4f86,\u4e26\u6c92\u6709\u6d3b\u52d5\u7d00\u9304
-gb.noActivityToday = \u4eca\u5929\u6c92\u6709\u6d3b\u52d5\u7d00\u9304
-gb.noComments = \u6c92\u6709\u5099\u8a3b
-gb.noDescriptionGiven = \u6c92\u6709\u7d66\u4e88\u7c21\u8ff0
-gb.noFederation = Sorry, {0} is not configured to federate with any Gitblit instances.
-gb.noForks = {0}\u6c92\u6709\u5206\u652f(fork)
-gb.noGitblitFound = Sorry, {0} could not find a Gitblit instance at {1}.
-gb.noHits = \u7121\u9ede\u64ca
-gb.noIndexedRepositoriesWarning = \u8ddf\u4f60\u76f8\u95dc\u7684\u6587\u4ef6\u5eab\u4e26\u6c92\u6709\u505aLucene\u7d22\u5f15
 gb.noMaximum = \u7121\u6700\u5927\u503c
-gb.noMilestoneSelected = \u672a\u9078\u53d6\u91cc\u7a0b\u7891
-gb.none = \u7121
-gb.nOpenTickets = {0}\u9805\u958b\u555f\u4e2d
-gb.noPermission = \u522a\u9664\u9019\u500b\u6b0a\u9650
-gb.noProposals = \u62b1\u6b49, {0}\u6b64\u6642\u4e26\u4e0d\u662f\u53ef\u63a5\u53d7\u7684\u8a08\u756b
-gb.noSelectedRepositoriesWarning = \u8acb\u81f3\u5c11\u9078\u64c7\u4e00\u500b\u6587\u4ef6\u5eab
-gb.notifyChangedOpenTickets = \u5df2\u958b\u555f\u7684\u4efb\u52d9\u55ae\u6709\u7570\u52d5\u8acb\u767c\u9001\u901a\u77e5
-gb.notRestricted = \u533f\u540d\u72c0\u614b\u53ef\u4ee5View, Clone\u8207Push
-gb.notSpecified = \u7121\u6307\u5b9a
-gb.nParticipants = {0}\u500b\u53c3\u8207
-gb.nTotalTickets = \u7e3d\u5171{0}\u9805
-gb.object = \u7269\u4ef6
-gb.of = \u7684
-gb.ok = ok
-gb.oneAttachment  = {0}\u500b\u9644\u4ef6
-gb.oneComment = {0}\u500b\u8a3b\u89e3
-gb.oneCommit = 1\u500b\u63d0\u4ea4
-gb.oneCommitTo = 1\u500b\u63d0\u4ea4\u5230
-gb.oneMoreCommit = \u9084\u6709\u4e00\u500b\u63d0\u4ea4  \u00bb
-gb.oneParticipant = {0}\u53c3\u8207
-gb.OneProposalToReview = \u6709\u4e00\u500b\u4e32\u9023\u7684\u63d0\u6848\u7b49\u5f85\u5be9\u67e5
-gb.opacityAdjust = Adjust opacity
-gb.open = \u958b\u555f
-gb.openMilestones = \u6253\u958b\u91cc\u7a0b\u7891
-gb.organization = \u7d44\u7e54
-gb.organizationalUnit = \u7d44\u7e54\u55ae\u4f4d
-gb.origin = origin
-gb.originDescription = \u6b64\u6587\u4ef6\u5eabURL\u5df2\u7d93\u88ab\u8907\u88fd(cloned)\u4e86
-gb.overdue = \u904e\u671f
-gb.overview = \u6982\u89c0
-gb.owned = \u64c1\u6709\u7684
-gb.owner = \u64c1\u6709\u8005
-gb.ownerDescription = \u64c1\u6709\u8005\u53ef\u4fee\u6539\u6587\u4ef6\u5eab\u8a2d\u5b9a\u503c
-gb.ownerPermission = \u6587\u4ef6\u5eab\u6240\u6709\u8005
-gb.owners = \u6240\u6709\u8005
-gb.ownersDescription = \u6240\u6709\u8005\u53ef\u4ee5\u7ba1\u7406\u6587\u4ef6\u5eab,\u4f46\u662f\u4e0d\u5141\u8a31\u4fee\u6539\u540d\u7a31(\u79c1\u4eba\u6587\u4ef6\u5eab\u4f8b\u5916)
-gb.pageFirst = \u7b2c\u4e00\u7b46
-gb.pageNext = \u4e0b\u4e00\u9801
-gb.pagePrevious = \u4e0a\u4e00\u9801
-gb.pages = \u6587\u4ef6
-gb.parent = \u4e0a\u500b\u7248\u672c
-gb.password = \u5bc6\u78bc
-gb.passwordChangeAborted = \u53d6\u6d88\u5bc6\u78bc\u8b8a\u66f4
-gb.passwordChanged = \u5bc6\u78bc\u8b8a\u66f4\u6210\u529f
-gb.passwordHint = \u5bc6\u78bc\u63d0\u793a
-gb.passwordHintRequired = \u5bc6\u78bc\u63d0\u793a(\u5fc5\u8981)
-gb.passwordsDoNotMatch = \u5bc6\u78bc\u4e0d\u76f8\u7b26
-gb.passwordTooShort = \u5bc6\u78bc\u904e\u77ed, \u6700\u5c11{0}\u500b\u5b57\u5143
-gb.patch = \u4fee\u88dc\u6a94
-gb.patchset = \u88dc\u4e01
-gb.patchsetAlreadyMerged = \u8a72\u88dc\u4e01\u5df2\u7d93\u5408\u4f75\u5230{0}
-gb.patchsetMergeable = \u8a72\u88dc\u4e01\u53ef\u4ee5\u81ea\u52d5\u8207{0}\u5408\u4f75
-gb.patchsetMergeableMore = \u4f7f\u7528\u547d\u4ee4\u529f\u80fd,\u8b93\u6b64\u88dc\u4e01\u53ef\u4ee5\u8207{0}\u5408\u4f75
-gb.patchsetN = \u88dc\u4e01{0}
-gb.patchsetNotApproved = \u8a72\u88dc\u4e01\u7248\u672c\u4e26\u6c92\u6709\u88ab\u6279\u51c6\u8207{0}\u5408\u4f75
-gb.patchsetNotApprovedMore = \u8a72\u88dc\u4e01\u5fc5\u9808\u7531\u5be9\u67e5\u8005\u6279\u51c6
-gb.patchsetNotMergeable = \u8a72\u88dc\u4e01\u4e0d\u80fd\u81ea\u52d5\u8207{0}\u5408\u4f75
-gb.patchsetNotMergeableMore = \u5fc5\u9808\u4ee5rebased\u6216\u662f\u624b\u52d5\u8207{0}\u5408\u4f75\u7684\u65b9\u5f0f\u624d\u80fd\u89e3\u6c7a\u8a72\u88dc\u4e01\u9020\u6210\u7684\u885d\u7a81
-gb.patchsetVetoedMore = \u5be9\u8996\u8005\u5df2\u7d93\u5c0d\u6b64\u88dc\u4e01\u6295\u7968
-gb.permission = \u6b0a\u9650
-gb.permissions = \u6b0a\u9650
-gb.permittedTeams = permitted teams
-gb.permittedUsers = permitted users
-gb.personalRepositories = \u500b\u4eba\u6587\u4ef6\u5eab
-gb.pleaseGenerateClientCertificate = \u8acb\u7522\u751f\u7d66{0}\u4f7f\u7528\u7684\u7528\u6236\u7aef\u8b49\u66f8
-gb.pleaseSelectGitIgnore = \u8acb\u9078\u64c7\u4e00\u500b.gitignore\u6a94\u6848
-gb.pleaseSelectProject = \u8acb\u9078\u64c7\u5c08\u6848!
-gb.pleaseSetDestinationUrl = Please enter a destination url for your proposal\!
-gb.pleaseSetGitblitUrl = \u8acb\u8f38\u5165Gitblit URL !
-gb.pleaseSetRepositoryName = \u8acb\u8a2d\u5b9a\u7248\u672c\u5eab\u540d\u7a31
-gb.pleaseSetTeamName = \u8acb\u8f38\u5165\u5718\u968a\u540d\u7a31
-gb.pleaseSetUsername = \u8acb\u8f38\u5165\u4f7f\u7528\u8005\u540d\u7a31
-gb.plugins = \u63d2\u4ef6
-gb.postReceiveDescription = \u63a5\u5230\u63d0\u4ea4\u7533\u8acb\u5f8c,<em>\u4e26\u4e14\u5728refs\u5b8c\u7562\u5f8c</em>, \u5c07\u6703\u57f7\u884cPost-receive hook..<p>This is the appropriate hook for notifications, build triggers, etc.</p>
-gb.postReceiveScripts = post-receive\u8173\u672c
-gb.preferences = \u9810\u8a2d\u5e38\u7528\u503c
-gb.preparingFork = \u6b63\u5728\u6e96\u5099\u8907\u88fd\u4e2d(fork)...
-gb.preReceiveDescription = \u63a5\u5230\u63d0\u4ea4\u7533\u8acb\u5f8c,<em>\u4f46\u5728\u9084\u6c92\u6709\u66f4\u65b0refs\u524d</em>, \u5c07\u6703\u57f7\u884cPre-receive hook. <p>This is the appropriate hook for rejecting a push.</p>
-gb.preReceiveScripts = pre-receive \u8173\u672c
-gb.preview = \u9810\u89bd
-gb.priority = \u512a\u5148
-gb.privilegeWithdrawn = \u53d6\u6d88\u6b0a\u9650
-gb.project = \u7fa4\u7d44
-gb.projects = \u7fa4\u7d44
-gb.properties = \u5c6c\u6027
-gb.proposal = \u63d0\u6848
-gb.proposalError = \u62b1\u6b49, {0} \u4efd\u5831\u544a\u767c\u751f\u9810\u671f\u5916\u7684\u932f\u8aa4!
-gb.proposalFailed = Sorry, {0} did not receive any proposal data\!
-gb.proposalReceived = Proposal successfully received by {0}.
-gb.proposals = \u8981\u6c42\u806f\u5408\u7684\u63d0\u6848
-gb.proposalTickets = \u63d0\u6848\u4fee\u6539
-gb.proposedThisChange = proposed this change
-gb.proposeInstructions = To start, craft a patchset and upload it with Git. Gitblit will link your patchset to this ticket by the id.
-gb.proposePatchset = \u63d0\u51fa\u88dc\u4e01
-gb.proposePatchsetNote = \u6b61\u8fce\u5c0d\u6b64\u4efb\u52d9\u55ae\u63d0\u4f9b\u88dc\u4e01
-gb.proposeWith = propose a patchset with {0}
-gb.ptCheckout = Fetch & checkout the current patchset to a review branch
-gb.ptDescription = the Gitblit patchset tool
-gb.ptDescription1 = Barnum is a command-line companion for Git that simplifies the syntax for working with Gitblit Tickets and Patchsets.
-gb.ptDescription2 = Barnum requires Python 3 and native Git. It runs on Windows, Linux, and Mac OS X.
-gb.ptMerge = \u53d6\u5f97\u76ee\u524d\u88dc\u4e01,\u7136\u5f8c\u8207\u4f60\u672c\u6a5f\u7aef\u7684\u5206\u652f\u5408\u4f75
-gb.ptSimplifiedCollaboration = simplified collaboration syntax
-gb.ptSimplifiedMerge = simplified merge syntax
-gb.publicKey = \u516c\u958b\u91d1\u9470
-gb.pushedNCommitsTo = {0}\u500b\u63d0\u4ea4\u5df2\u63a8\u9001\u81f3
-gb.pushedNewBranch = \u65b0\u5206\u652f\u5df2\u63a8\u9001(pushed)
-gb.pushedNewTag = \u65b0\u6a19\u7c64\u5df2\u63a8\u9001(pushed)
-gb.pushedOneCommitTo = 1\u500b\u63d0\u4ea4\u5df2\u63a8\u9001\u81f3
-gb.pushPermission = {0}(\u63a8\u9001)
-gb.pushRestricted = authenticated push
-gb.queries = \u67e5\u8a62\u7d50\u679c
-gb.query = \u67e5\u8a62
-gb.queryHelp = \u652f\u63f4\u6a19\u6e96\u67e5\u8a62\u8a9e\u6cd5.<p/><p/>\u8a73\u60c5\u8acb\u53c3\u8003 <a target\ = "_new" href\ = "http\://lucene.apache.org/core/old_versioned_docs/versions/3_5_0/queryparsersyntax.html">Lucene Query Parser Syntax</a>
-gb.queryResults = results {0} - {1} ({2} hits)
-gb.questionTickets = \u63d0\u554f
-gb.raw = \u539f\u59cb
-gb.reason = \u539f\u56e0
-gb.receive = \u63a5\u6536
-gb.received = \u5df2\u63a5\u6536
-gb.receiveSettings = \u8a2d\u5b9a\u63a5\u6536\u65b9\u5f0f
-gb.receiveSettingsDescription = \u63a7\u7ba1\u63a8\u9001\u5230\u6587\u4ef6\u5eab\u7684\u63a5\u6536\u65b9\u5f0f
-gb.recent = \u6700\u8fd1
-gb.recentActivity = \u6700\u8fd1\u6d3b\u8e8d\u72c0\u6cc1
-gb.recentActivityNone = \u904e\u53bb{0}\u5929/\u7121
-gb.recentActivityStats = \u904e\u53bb{0}\u5929,\u4e00\u5171\u6709{2}\u4eba\u57f7\u884c{1}\u4efd\u63d0\u4ea4
-gb.reflog = \u76f8\u95dc\u65e5\u8a8c
-gb.refresh = \u5237\u65b0
-gb.refs = \u5f15\u7528
-gb.regexPermission = \u5df2\u7d93\u4f7f\u7528\u6b63\u898f\u8868\u793a\u5f0f(regular expression)"{0}" \u8a2d\u5b9a\u6b0a\u9650\u5b8c\u7562
-gb.registration = \u8a3b\u518a
-gb.registrations = federation registrations
-gb.releaseDate = \u767c\u8868\u65e5
-gb.remote = \u9060\u7aef
-gb.removeVote = \u79fb\u9664\u6295\u7968
-gb.rename = \u6539\u540d\u7a31
-gb.repositories = \u6587\u4ef6\u5eab
-gb.repository = \u7248\u672c\u5eab
-gb.repositoryDeleted = \u7248\u672c\u5eab"{0}"\u5df2\u522a\u9664
-gb.repositoryDeleteFailed = \u522a\u9664\u7248\u672c\u5eab"{0}"\u5931\u6557!
-gb.repositoryDoesNotAcceptPatchsets = \u8a72\u7248\u672c\u5eab\u4e0d\u63a5\u53d7\u88dc\u4e01
-gb.repositoryForked = \u7248\u672c\u5eab{0}\u5df2\u7d93\u5efa\u7acb\u5206\u652f(fork)
-gb.repositoryForkFailed= \u5efa\u7acb\u5206\u652f(fork)\u5931\u6557
-gb.repositoryIsFrozen = \u8a72\u7248\u672c\u5eab\u5df2\u51cd\u7d50
-gb.repositoryIsMirror = \u8a72\u7248\u672c\u5eab\u70ba\u552f\u8b80\u8907\u672c
-gb.repositoryNotSpecified = \u672a\u6307\u5b9a\u7248\u672c\u5eab!
-gb.repositoryNotSpecifiedFor = \u7248\u672c\u5eab\u4e26\u6c92\u6709\u6307\u5b9a\u7d66 {0}\!
-gb.repositoryPermissions = \u7248\u672c\u5eab\u6b0a\u9650
-gb.repositoryUrl = \u7248\u672c\u5eab url
-gb.requestTickets = \u512a\u5316 & \u4efb\u52d9
-gb.requireApproval = \u9700\u6279\u51c6
-gb.requireApprovalDescription = \u5408\u4f75\u6309\u9215\u555f\u7528\u524d,\u88dc\u4e01\u5305\u5fc5\u9808\u5148\u6279\u51c6
-gb.reset = \u6e05\u9664
-gb.responsible = \u8ca0\u8cac\u4eba\u54e1
-gb.restrictedRepositories = restricted repositories
-gb.review = \u8907\u67e5(review)
-gb.reviewedPatchsetRev = reviewed patchset {0} revision {1}\: {2}
-gb.reviewers = \u5be9\u67e5\u8005
-gb.reviewPatchset = review {0} patchset {1}
-gb.reviews = reviews
-gb.revisionHistory = \u4fee\u6539\u7d00\u9304
-gb.revokeCertificate = \u64a4\u56de\u8b49\u66f8
-gb.revokeCertificateReason = \u8acb\u8f38\u5165\u64a4\u56de\u8b49\u66f8\u7406\u7531
-gb.revoked = \u5df2\u64a4\u92b7
-gb.rewind = REWIND
-gb.rewindPermission = {0} (push, ref creation+deletion+rewind)
-gb.save = \u5132\u5b58
-gb.search = \u641c\u5c0b
-gb.searchForAuthor = Search for commits authored by
-gb.searchForCommitter = Search for commits committed by
-gb.searchTickets = \u641c\u5c0b\u4efb\u52d9\u55ae
-gb.searchTicketsTooltip = \u627e\u5230{0}\u4efd\u4efb\u52d9\u55ae
-gb.searchTooltip = \u641c\u5c0b{0}
-gb.searchTypeTooltip = \u9078\u64c7\u641c\u5c0b\u985e\u578b
-gb.selectAccessRestriction = Please select access restriction\!
-gb.selected = \u9078\u5b9a
-gb.selectFederationStrategy = Please select federation strategy\!
-gb.sendEmail = \u767cemail
-gb.sendProposal = \u63d0\u6848
-gb.serialNumber = \u5e8f\u865f
+gb.attributes = \u5c6c\u6027
 gb.serveCertificate = \u555f\u7528\u4f7f\u7528\u6b64\u8b49\u66f8\u7684https\u529f\u80fd
-gb.serverDoesNotAcceptPatchsets = \u672c\u4f3a\u670d\u5668\u4e0d\u63a5\u53d7\u88dc\u4e01
-gb.servers = \u4f3a\u670d\u5668
-gb.servletContainer = servlet\u5bb9\u5668
-gb.sessionEnded = session\u5df2\u7d93\u53d6\u6d88
-gb.setDefault = \u8a2d\u70ba\u9810\u8a2d\u503c
-gb.settings = \u8a2d\u5b9a
-gb.severity =  \u91cd\u8981
-gb.sha1FingerPrint = SHA-1 Fingerprint
-gb.show_whitespace = \u986f\u793a\u7a7a\u767d
-gb.showHideDetails = \u986f\u793a/\u96b1\u85cf \u8a73\u89e3\u5167\u5bb9
-gb.showReadme = \u986f\u793areadme\u6587\u4ef6
-gb.showReadmeDescription = \u5728\u532f\u7e3d\u9801\u9762\u4e2d\u986f\u793a"readme"(markdown\u683c\u5f0f)
-gb.showRemoteBranches = \u986f\u793a\u9060\u7aef\u5206\u652f
-gb.showRemoteBranchesDescription = \u986f\u793a\u9060\u7aef\u5206\u652f(branches)
-gb.signatureAlgorithm = \u7c3d\u7ae0\u6f14\u7b97\u6cd5
-gb.since = \u5f9e
-gb.siteName = \u7ad9\u53f0\u540d\u7a31
-gb.siteNameDescription = \u4f3a\u670d\u5668\u7c21\u7a31
-gb.size = \u5bb9\u91cf
-gb.skipSizeCalculation = \u7565\u904e\u5bb9\u91cf\u8a08\u7b97
-gb.skipSizeCalculationDescription = \u4e0d\u8a08\u7b97\u6587\u4ef6\u5eab\u5bb9\u91cf(\u52a0\u5feb\u7db2\u9801\u8f09\u5165\u901f\u5ea6)
-gb.skipSummaryMetrics = \u7565\u904e\u91cf\u5316\u532f\u7e3d
-gb.skipSummaryMetricsDescription = \u4e0d\u8981\u8a08\u7b97\u91cf\u5316\u4e26\u4e14\u986f\u793a\u5728\u532f\u7e3d\u9801\u9762\u4e0a(\u52a0\u5feb\u901f\u5ea6)
-gb.sort = \u6392\u5e8f
-gb.sortHighestPriority = \u6700\u9ad8\u512a\u5148
-gb.sortHighestSeverity = \u6700\u91cd\u8981
-gb.sortLeastComments = \u6700\u5c11\u5099\u8a3b
-gb.sortLeastPatchsetRevisions = \u6700\u5c11\u88dc\u4e01\u4fee\u6539
-gb.sortLeastRecentlyUpdated = \u6700\u8fd1\u6700\u5c11\u8b8a\u52d5
-gb.sortLeastVotes = \u6700\u5c11\u6295\u7968
-gb.sortLowestPriority = \u6700\u4f4e\u512a\u5148
-gb.sortLowestSeverity = \u6700\u4e0d\u91cd\u8981
-gb.sortMostComments = \u6700\u591a\u5099\u8a3b
-gb.sortMostPatchsetRevisions = \u6700\u591a\u88dc\u4e01\u4fee\u6b63
-gb.sortMostRecentlyUpdated = \u6700\u8fd1\u66f4\u65b0
-gb.sortMostVotes = \u6700\u591a\u6295\u7968
-gb.sortNewest = \u6700\u65b0
-gb.sortOldest = \u6700\u820a
-gb.specified = \u6307\u5b9a\u7d66\u4e88(\u542b\u7cfb\u7d71\u9810\u8a2d)
-gb.sshKeyCommentDescription = \u8acb\u8f38\u5165\u5099\u8a3b, \u82e5\u7121\u5099\u8a3b, \u5c07\u81ea\u8a02\u586b\u5165key data
-gb.sshKeyPermissionDescription = \u6307\u5b9a\u8a72SSH key\u6240\u64c1\u6709\u7684\u5b58\u53d6\u6b0a\u9650
-gb.sshKeys = SSH Keys
-gb.sshKeysDescription = SSH \u516c\u958b\u91d1\u9470\u662f\u5bc6\u78bc\u8a8d\u8b49\u5916\u66f4\u5b89\u5168\u7684\u9078\u9805
-gb.sslCertificateGenerated = \u6210\u529f\u7522\u751f\u7d66{0}\u7684\u670d\u5668SSL\u8b49\u66f8
 gb.sslCertificateGeneratedRestart = \u6210\u529f\u7522\u751f\u7d66{0}\u4f7f\u7528\u7684SSL\u8b49\u66f8\n\u4f60\u5fc5\u9808\u91cd\u65b0\u555f\u52d5Gitblit\u7248\u63a7\u4f3a\u670d\u5668\u624d\u80fd\u555f\u7528\u65b0\u7684\u8b49\u66f8\n\nf you are launching with the '--alias' parameter you will have to set that to ''--alias {0}''.
+gb.validity = validity
+gb.siteName = \u7db2\u7ad9\u540d\u7a31
+gb.siteNameDescription = \u4f3a\u670d\u5668\u7c21\u7a31
+gb.excludeFromActivity = exclude from activity page
+gb.isSparkleshared = \u8a72\u7248\u672c\u5eab\u5df2\u70baSparkleshared (http://sparkleshare.org)
+gb.owners = \u64c1\u6709\u8005
+gb.sessionEnded = session\u5df2\u7d93\u53d6\u6d88
+gb.closeBrowser = \u8acb\u95dc\u9589\u700f\u89bd\u5668\u7d50\u675f\u6b64\u767b\u5165\u968e\u6bb5
+gb.doesNotExistInTree = {0}\u4e26\u6c92\u6709\u5728\u76ee\u9304{1}\u88e1\u9762
+gb.enableIncrementalPushTags = \u555f\u7528\u81ea\u52d5\u65b0\u589e\u6a19\u7c64\u529f\u80fd
+gb.useIncrementalPushTagsDescription = \u63a8\u9001\u6642\u5c07\u81ea\u52d5\u65b0\u589e\u6a19\u7c64\u865f\u78bc
+gb.incrementalPushTagMessage = \u7576[{0}]\u5206\u652f\u63a8\u9001\u5f8c,\u81ea\u52d5\u7d66\u4e88\u6a19\u7c64\u865f.
+gb.externalPermissions = {0} access permissions are externally maintained
+gb.viewAccess = \u4f60\u6c92\u6709Gitblit\u8b80\u53d6\u6216\u662f\u4fee\u6539\u6b0a\u9650
+gb.overview = \u6982\u89c0
+gb.dashboard = \u5100\u8868\u677f
+gb.monthlyActivity = \u6708\u6d3b\u52d5
+gb.myProfile = \u6211\u7684\u57fa\u672c\u8cc7\u6599
+gb.compare = \u6bd4\u5c0d
+gb.manual = \u81ea\u884c\u8f38\u5165
+gb.from = \u5f9e
+gb.to = \u81f3
+gb.at = at
+gb.of = \u5c08\u6848\u70ba
+gb.in = in
+gb.moreChanges = \u6240\u6709\u8b8a\u66f4...
+gb.pushedNCommitsTo = {0}\u500b\u63d0\u4ea4\u5df2\u63a8\u9001\u81f3
+gb.pushedOneCommitTo = 1\u500b\u63d0\u4ea4\u5df2\u63a8\u9001\u81f3
+gb.commitsTo = {0} \u4efd\u63d0\u4ea4\u81f3\u5206\u652f
+gb.oneCommitTo = 1\u500b\u63d0\u4ea4\u81f3\u5206\u652f
+gb.byNAuthors = \u7d93\u7531{0}\u500b\u4f5c\u8005
+gb.byOneAuthor = \u7d93\u7531{0}
+gb.viewComparison = \u6bd4\u8f03\u9019{0}\u500b\u63d0\u4ea4 \u00bb
+gb.nMoreCommits = \u9084\u6709{0}\u4efd\u63d0\u4ea4 \u00bb
+gb.oneMoreCommit = \u9084\u6709\u4e00\u500b\u63d0\u4ea4  \u00bb
+gb.pushedNewTag = \u65b0\u6a19\u7c64\u5df2\u63a8\u9001(pushed)
+gb.createdNewTag = \u5efa\u7acb\u65b0\u6a19\u7c64
+gb.deletedTag = \u522a\u9664\u6a19\u7c64
+gb.pushedNewBranch = \u5df2\u63a8\u9001(pushed)\u4e4b\u5206\u652f -
+gb.createdNewBranch = \u5efa\u7acb\u65b0\u5206\u652f
+gb.deletedBranch = \u5df2\u522a\u9664\u7684\u5206\u652f
+gb.createdNewPullRequest = \u5efa\u7acb pull request
+gb.mergedPullRequest = \u5408\u4f75\u63a8\u9001\u8981\u6c42
+gb.rewind = REWIND
 gb.star = \u91cd\u8981
-gb.stargazers = stargazers
-gb.starred = \u91cd\u8981
-gb.starredAndOwned = \u91cd\u8981\u7684 & \u64c1\u6709\u7684
-gb.starredRepositories = \u91cd\u8981\u7684\u6587\u4ef6\u5eab
-gb.starting = \u555f\u52d5\u4e2d
-gb.stateProvince = \u5dde\u6216\u7701
-gb.stats = \u7d71\u8a08
-gb.status = \u72c0\u614b
-gb.stepN = \u6b65\u9a5f{0}
-gb.stopWatching = \u505c\u6b62\u8ffd\u8e64(watching)
-gb.subject = \u6a19\u984c
-gb.subscribe = \u8a02\u95b1
-gb.summary = \u532f\u7e3d
-gb.superseded = \u5df2\u88ab\u66ff\u4ee3
-gb.tag = \u6a19\u7c64
-gb.tagger = tagger
-gb.tags = \u6a19\u7c64
-gb.taskTickets = \u4efb\u52d9
-gb.team = \u5718\u968a
-gb.teamCreated = \u5718\u968a"{0}"\u65b0\u589e\u6210\u529f.
-gb.teamMembers = \u5718\u968a\u6210\u54e1
-gb.teamMemberships = \u5718\u968a\u6210\u54e1(memberships)
-gb.teamMustSpecifyRepository = \u5718\u968a\u6700\u5c11\u8981\u6307\u5b9a\u4e00\u500b\u7248\u672c\u5eab
-gb.teamName = \u5718\u968a\u540d\u7a31
-gb.teamNameUnavailable = \u5718\u968a"{0}"\u4e0d\u5b58\u5728.
-gb.teamPermission = "{0}" \u5718\u968a\u6210\u54e1\u7684\u6b0a\u9650
-gb.teamPermissions = \u5718\u968a\u6b0a\u9650
-gb.teamPermissionsDescription = \u4f60\u53ef\u4ee5\u6307\u5b9a\u5718\u968a\u6b0a\u9650.\u9019\u4e9b\u8a2d\u5b9a\u5c07\u6703\u53d6\u4ee3\u539f\u672c\u5718\u968a\u9810\u8a2d\u6b0a\u9650
-gb.teams = \u53c3\u8207\u7684\u5718\u968a
-gb.ticket = \u4efb\u52d9\u55ae
-gb.ticketAssigned = \u5df2\u6307\u5b9a
-gb.ticketComments = \u8a3b\u89e3
-gb.ticketId = \u4efb\u52d9\u55aeID
-gb.ticketIsClosed = \u8a72\u4efb\u52d9\u55ae\u5df2\u7d93\u7d50\u6848
-gb.ticketN = \u4efb\u52d9\u55ae\u865f#{0}
-gb.ticketOpenDate = \u767c\u884c\u65e5
-gb.ticketPatchset = {0}\u4efb\u52d9\u55ae,{1}\u88dc\u4e01
-gb.tickets = \u4efb\u52d9\u55ae
-gb.ticketSettings = \u4efb\u52d9\u55ae\u5167\u5bb9\u8a2d\u5b9a
-gb.ticketStatus = \u72c0\u614b
-gb.ticketsWelcome = \u4f60\u53ef\u4ee5\u5229\u7528\u4efb\u52d9\u55ae\u7cfb\u7d71\u5efa\u69cb\u51fa\u5f85\u8fa6\u4e8b\u9805, \u81ed\u87f2\u56de\u5831\u5340\u4ee5\u53ca\u88dc\u4e01\u5305\u7684\u5354\u540c\u5408\u4f5c
-gb.time.daysAgo = {0}\u5929\u524d
-gb.time.hoursAgo = {0}\u5c0f\u6642\u524d
-gb.time.inDays = {0}\u5929\u5167
-gb.time.inHours = {0}\u5c0f\u6642\u5167
-gb.time.inMinutes = {0}\u5206\u9418\u5167
-gb.time.justNow = \u525b\u525b
-gb.time.minsAgo = {0}\u5206\u9418\u524d
-gb.time.monthsAgo = {0}\u6708\u524d
-gb.time.oneYearAgo = 1\u5e74\u524d
-gb.time.today = \u4eca\u5929
-gb.time.weeksAgo = {0}\u5468\u524d
-gb.time.yearsAgo = {0}\u5e74\u524d
-gb.time.yesterday = \u6628\u5929
-gb.title = \u6a19\u984c
-gb.to = to
-gb.toBranch = to {0}
-gb.todaysActivityNone = \u4eca\u5929/\u7121
-gb.todaysActivityStats = \u4eca\u5929/\u6709{2}\u500b\u4f5c\u8005\u5b8c\u6210{1}\u500b\u63d0\u4ea4
-gb.token = token
-gb.tokenAllDescription = \u6240\u6709\u7248\u672c\u5eab,\u4f7f\u7528\u8005\u8207\u8a2d\u5b9a
-gb.tokenJurDescription = \u6240\u6709\u7248\u672c\u5eab
-gb.tokens = federation tokens
-gb.tokenUnrDescription = \u6240\u6709\u7248\u672c\u5eab\u8207\u4f7f\u7528\u8005
-gb.topic = \u8a71\u984c
-gb.topicsAndLabels = \u8a71\u984c\u8207\u6a19\u8a18
-gb.transportPreference = \u9810\u8a2d\u901a\u8a0a\u5354\u5b9a
-gb.transportPreferenceDescription = \u8a2d\u5b9a\u4f60\u5e38\u7528\u7684\u9023\u7dda\u901a\u8a0a\u5354\u5b9a\u4ee5\u7528\u4f86\u8907\u88fd(clone)
-gb.tree = \u76ee\u9304
-gb.type = \u985e\u578b
-gb.unauthorizedAccessForRepository = \u7248\u672c\u5eab\u672a\u6388\u6b0a\u5b58\u53d6
-gb.undefinedQueryWarning = \u672a\u8a2d\u5b9a\u67e5\u8a62\u689d\u4ef6
-gb.unspecified = \u672a\u6307\u5b9a
 gb.unstar = \u53d6\u6d88
-gb.updated = \u5df2\u66f4\u65b0
-gb.updatedBy = updated by
+gb.stargazers = stargazers
+gb.starredRepositories = \u91cd\u8981\u7684\u7248\u672c\u5eab
+gb.failedToUpdateUser = \u7121\u6cd5\u66f4\u65b0\u4f7f\u7528\u8005\u5e33\u865f
+gb.myRepositories = \u6211\u7684\u7248\u672c\u5eab
+gb.noActivity = \u904e\u53bb{0}\u5929\u4f86,\u4e26\u6c92\u6709\u6d3b\u52d5\u7d00\u9304
+gb.findSomeRepositories = \u641c\u5c0b\u7248\u672c\u5eab
+gb.metricAuthorExclusions = \u7d71\u8a08\u6642\u6392\u9664\u6d3b\u8e8d\u5e33\u6236
+gb.myDashboard = \u5100\u8868\u677f
+gb.failedToFindAccount = \u7121\u6cd5\u641c\u5c0b\u5230\u5e33\u865f"{0}"
+gb.reflog = \u76f8\u95dc\u65e5\u8a8c
+gb.active = \u6d3b\u52d5
+gb.starred = \u91cd\u8981
+gb.owned = \u64c1\u6709
+gb.starredAndOwned = \u91cd\u8981 & \u64c1\u6709
+gb.reviewPatchset = {0}\u500breview  {1}\u500bpatchset
+gb.todaysActivityStats = \u4eca\u5929/\u6709{2}\u500b\u4f5c\u8005\u5b8c\u6210{1}\u500b\u63d0\u4ea4
+gb.todaysActivityNone = \u4eca\u5929/\u7121
+gb.noActivityToday = \u4eca\u5929\u6c92\u6709\u6d3b\u52d5\u7d00\u9304
+gb.anonymousUser = \u533f\u540d
+gb.commitMessageRenderer = \u63d0\u4ea4\u8a0a\u606f\u5448\u73fe\u65b9\u5f0f
+gb.diffStat = \u65b0\u589e{0}\u5217\u8207\u522a\u9664{1}\u5217
+gb.home = \u9996\u9801
+gb.isMirror = \u8a72\u7248\u672c\u5eab\u70ba\u93e1\u50cf(mirror)
+gb.mirrorOf = {0}\u7684\u93e1\u50cf
+gb.mirrorWarning = \u8a72\u7248\u672c\u5eab\u5c6c\u65bc\u93e1\u50cf, \u4e0d\u80fd\u5920\u63a5\u6536\u63a8\u9001(push)
+gb.docsWelcome1 = \u4f60\u53ef\u4ee5\u4f7f\u7528\u6a94\u6848\u5340\u5efa\u7acb\u7248\u672c\u5eab\u7684\u6559\u5b78\u6a94\u6848
+gb.docsWelcome2 = \u63d0\u4ea4README.md \u6216 HOME.md\u5f8c,\u518d\u958b\u59cb\u65b0\u7684\u7248\u672c\u5eab
+gb.createReadme = \u5efa\u7acbREADME\u6a94\u6848
+gb.responsible = \u8ca0\u8cac\u4eba\u54e1
+gb.createdThisTicket = \u5df2\u958b\u7acb\u7684\u4efb\u52d9
+gb.proposedThisChange = proposed this change
 gb.uploadedPatchsetN = \u88dc\u4e01{0}\u5df2\u4e0a\u50b3
 gb.uploadedPatchsetNRevisionN = \u88dc\u4e01{0}\u4fee\u6539\u7248\u672c{1}\u5df2\u4e0a\u50b3
-gb.url = URL
-gb.useDocsDescription = \u8a08\u7b97\u6587\u4ef6\u5eab\u88e1\u9762\u7684Markdown\u6a94\u6848
-gb.useIncrementalPushTagsDescription = \u63a8\u9001\u6642\u5c07\u81ea\u52d5\u65b0\u589e\u6a19\u7c64\u865f\u78bc
-gb.userCreated = \u6210\u529f\u5efa\u7acb\u65b0\u4f7f\u7528\u8005"{0}"
-gb.userDeleted = \u4f7f\u7528\u8005"{0}"\u5df2\u522a\u9664
-gb.userDeleteFailed = \u4f7f\u7528\u8005"{0}"\u522a\u9664\u5931\u6557
-gb.username = \u4f7f\u7528\u8005\u540d\u7a31
-gb.usernameUnavailable = \u4f7f\u7528\u8005\u540d\u7a31"{0}"\u4e0d\u53ef\u7528
-gb.userPermissions = \u4f7f\u7528\u8005\u6b0a\u9650
-gb.userPermissionsDescription = \u4f60\u53ef\u4ee5\u91dd\u5c0d\u5e33\u865f\u8a2d\u5b9a\u6b0a\u9650(\u9019\u4e9b\u8a2d\u5b9a\u5c07\u8986\u84cb\u5718\u968a\u6216\u5176\u4ed6\u6b0a\u9650)
-gb.users = \u4f7f\u7528\u8005
-gb.userServiceDoesNotPermitAddUser = {0}\u4e0d\u5141\u8a31\u65b0\u589e\u4f7f\u7528\u8005\u5e33\u865f
-gb.userServiceDoesNotPermitPasswordChanges = {0}\u4e0d\u5141\u8a31\u4fee\u6539\u5bc6\u78bc
-gb.useTicketsDescription = readonly, distributed Ticgit issues
-gb.validFrom = valid from
-gb.validity = validity
-gb.validUntil = valid until
-gb.verifyCommitter = \u63d0\u4ea4\u8005\u9700\u9a57\u8b49
-gb.verifyCommitterDescription = \u9700\u8981\u63d0\u4ea4\u8005\u7b26\u5408\u63a8\u9001\u5e33\u865f
-gb.verifyCommitterNote = \u6240\u6709\u5408\u4f75\u52d5\u4f5c\u7686\u9808\u5f37\u5236\u4f7f\u7528"--no-ff"\u53c3\u6578
-gb.version = \u7248\u672c
-gb.veto = veto
-gb.view = \u6aa2\u8996
-gb.viewAccess = \u4f60\u6c92\u6709Gitblit\u8b80\u53d6\u6216\u662f\u4fee\u6539\u6b0a\u9650
-gb.viewCertificate = \u6aa2\u8996\u8b49\u66f8
-gb.viewComparison = \u6bd4\u8f03\u9019{0}\u500b\u63d0\u4ea4 \u00bb
-gb.viewPermission = {0} (\u6aa2\u8996)
-gb.viewPolicy  = Restrict View, Clone, & Push
-gb.viewPolicyDescription = \u9078\u64c7\u53ef\u4ee5\u5728\u6587\u4ef6\u5eab\u6aa2\u8996,\u8907\u88fd(clone)\u8207\u63a8\u9001(push)\u7684\u4f7f\u7528\u8005, \u9664\u6b64\u4e4b\u5916\u5176\u4ed6\u4eba\u7686\u7121\u6b0a\u9650
-gb.viewRestricted = authenticated view, clone, & push
+gb.mergedPatchset = \u5c07\u88dc\u4e01\u5408\u4f75
+gb.commented = \u5df2\u8a3b\u89e3
+gb.noDescriptionGiven = \u6c92\u6709\u7d66\u4e88\u7c21\u8ff0
+gb.toBranch = \u5230\u5206\u652f {0}
+gb.createdBy = \u5efa\u7acb\u8005
+gb.oneParticipant = {0}\u53c3\u8207
+gb.nParticipants = {0}\u500b\u53c3\u8207
+gb.noComments = \u6c92\u6709\u5099\u8a3b
+gb.oneComment = {0}\u500b\u8a3b\u89e3
+gb.nComments = {0}\u500b\u8a3b\u89e3
+gb.oneAttachment = {0}\u500b\u9644\u4ef6
+gb.nAttachments = {0}\u500b\u9644\u4ef6
+gb.milestone = milestone
+gb.compareToMergeBase = \u6bd4\u5c0d\u5f8c,\u5408\u4f75\u5230\u4e3b\u8981\u5de5\u4f5c\u5340
+gb.compareToN = \u8207{0}\u9032\u884c\u6bd4\u5c0d
+gb.open = \u958b\u555f
+gb.closed = \u95dc\u9589
+gb.merged = \u5df2\u5408\u4f75
+gb.ticketPatchset = {0}\u4efb\u52d9\u55ae,{1}\u88dc\u4e01
+gb.patchsetMergeable = \u8a72\u88dc\u4e01\u53ef\u4ee5\u81ea\u52d5\u8207{0}\u5408\u4f75
+gb.patchsetMergeableMore = \u4f7f\u7528\u547d\u4ee4\u529f\u80fd,\u8b93\u6b64\u88dc\u4e01\u53ef\u4ee5\u8207{0}\u5408\u4f75
+gb.patchsetAlreadyMerged = \u8a72\u88dc\u4e01\u5df2\u7d93\u5408\u4f75\u5230{0}
+gb.patchsetNotMergeable = \u8a72\u88dc\u4e01\u4e0d\u80fd\u81ea\u52d5\u8207{0}\u5408\u4f75
+gb.patchsetNotMergeableMore = \u5fc5\u9808\u4ee5rebased\u6216\u662f\u624b\u52d5\u8207{0}\u5408\u4f75\u7684\u65b9\u5f0f\u624d\u80fd\u89e3\u6c7a\u8a72\u88dc\u4e01\u9020\u6210\u7684\u885d\u7a81
+gb.patchsetNotApproved = \u8a72\u88dc\u4e01\u7248\u672c\u4e26\u6c92\u6709\u88ab\u6279\u51c6\u8207{0}\u5408\u4f75
+gb.patchsetNotApprovedMore = \u8a72\u88dc\u4e01\u5fc5\u9808\u7531\u5be9\u67e5\u8005\u6279\u51c6
+gb.patchsetVetoedMore = \u5be9\u67e5\u8005\u5df2\u7d93\u5c0d\u6b64\u88dc\u4e01\u6295\u7968
+gb.write = \u8f38\u5165
+gb.comment = \u8a3b\u89e3
+gb.preview = \u9810\u89bd
+gb.leaveComment = \u7559\u4e0b\u8a3b\u89e3
+gb.showHideDetails = \u986f\u793a/\u96b1\u85cf \u8a73\u89e3\u5167\u5bb9
+gb.acceptNewPatchsets = \u5141\u8a31\u88dc\u4e01
+gb.acceptNewPatchsetsDescription = \u63a5\u53d7\u5230\u7248\u672c\u5eab\u9032\u884c\u4fee\u88dc\u52d5\u4f5c
+gb.acceptNewTickets = \u5141\u8a31\u5efa\u7acb\u4efb\u52d9
+gb.acceptNewTicketsDescription = \u5141\u8a31\u65b0\u589e"\u81ed\u87f2","\u512a\u5316","\u4efb\u52d9"\u5404\u985e\u578b\u4efb\u52d9
+gb.requireApproval = \u9700\u6279\u51c6
+gb.requireApprovalDescription = \u5408\u4f75\u6309\u9215\u555f\u7528\u524d,\u88dc\u4e01\u5305\u5fc5\u9808\u5148\u6279\u51c6
+gb.topic = \u8a71\u984c
+gb.proposalTickets = \u63d0\u6848\u4fee\u6539
+gb.bugTickets = \u81ed\u87f2
+gb.enhancementTickets = \u512a\u5316
+gb.taskTickets = \u4efb\u52d9
+gb.questionTickets = \u63d0\u554f
+gb.requestTickets = \u512a\u5316 & \u4efb\u52d9
+gb.yourCreatedTickets = \u4f60\u65b0\u589e\u7684
+gb.yourWatchedTickets = \u4f60\u76e3\u770b\u7684
+gb.mentionsMeTickets = \u8207\u4f60\u76f8\u95dc
+gb.updatedBy = \u66f4\u65b0\u8005
+gb.sort = \u6392\u5e8f
+gb.sortNewest = \u6700\u65b0
+gb.sortOldest = \u6700\u820a
+gb.sortMostRecentlyUpdated = \u6700\u8fd1\u66f4\u65b0
+gb.sortLeastRecentlyUpdated = \u6700\u8fd1\u6700\u5c11\u8b8a\u52d5
+gb.sortMostComments = \u6700\u591a\u5099\u8a3b
+gb.sortLeastComments = \u6700\u5c11\u5099\u8a3b
+gb.sortMostPatchsetRevisions = \u6700\u591a\u88dc\u4e01\u4fee\u6b63
+gb.sortLeastPatchsetRevisions = \u6700\u5c11\u88dc\u4e01\u4fee\u6539
+gb.sortMostVotes = \u6700\u591a\u6295\u7968
+gb.sortLeastVotes = \u6700\u5c11\u6295\u7968
+gb.topicsAndLabels = \u8a71\u984c\u8207\u6a19\u8a18
+gb.milestones = milestones
+gb.noMilestoneSelected = \u672a\u9078\u53d6milestone
+gb.notSpecified = \u7121\u6307\u5b9a
+gb.due = \u622a\u6b62
+gb.queries = \u67e5\u8a62\u7d50\u679c
+gb.searchTicketsTooltip = \u627e\u5230{0}\u4efd\u4efb\u52d9
+gb.searchTickets = \u641c\u5c0b\u4efb\u52d9
+gb.new = \u5efa\u7acb
+gb.newTicket = \u65b0\u589e\u4efb\u52d9
+gb.editTicket = \u4fee\u6539\u4efb\u52d9
+gb.ticketsWelcome = \u4f60\u53ef\u4ee5\u5229\u7528\u4efb\u52d9\u7cfb\u7d71\u5efa\u69cb\u51fa\u5f85\u8fa6\u4e8b\u9805, \u81ed\u87f2\u56de\u5831\u5340\u4ee5\u53ca\u88dc\u4e01\u5305\u7684\u5354\u540c\u5408\u4f5c
+gb.createFirstTicket = \u6309\u6b64\u5efa\u7acb\u7b2c\u4e00\u500b\u4efb\u52d9
+gb.title = \u6a19\u984c
+gb.changedStatus = changed the status
+gb.discussion = \u8a0e\u8ad6
+gb.updated = \u5df2\u66f4\u65b0
+gb.proposePatchset = \u63d0\u51fa\u88dc\u4e01
+gb.proposePatchsetNote = \u6b61\u8fce\u5c0d\u6b64\u4efb\u52d9\u63d0\u4f9b\u88dc\u4e01
+gb.proposeInstructions = \u9996\u5148, \u5efa\u7acb\u88dc\u4e01\u4e26\u4e14\u540c\u6b65\u5230\u6b64gitblit\u4f3a\u670d\u5668. Gitblit \u8b93\u88dc\u4e01\u8207\u672c\u6b21\u4efb\u52d9ID(ticket ID)\u9023\u7d50.
+gb.proposeWith = \u5982\u4f55\u5728{0} \u4e0a\u5efa\u7acb\u88dc\u4e01
+gb.revisionHistory = \u4fee\u6539\u7d00\u9304
+gb.merge = \u5408\u4f75
+gb.action = \u52d5\u4f5c
+gb.patchset = \u88dc\u4e01
+gb.all = \u5168\u90e8
+gb.mergeBase = \u57fa\u672c\u5408\u4f75
+gb.checkout = \u6aa2\u51fa(checkout)
+gb.checkoutViaCommandLine = \u4f7f\u7528\u6307\u4ee4Checkout
+gb.checkoutViaCommandLineNote = \u4f60\u53ef\u4ee5\u5f9e\u4f60\u7248\u672c\u5eab\u4e2dcheckout\u4e00\u4efd,\u7136\u5f8c\u9032\u884c\u6e2c\u8a66
+gb.checkoutStep1 = Fetch the current patchset \u2014 run this from your project directory
+gb.checkoutStep2 = \u5c07\u8a72\u88dc\u4e01\u8f49\u51fa\u5230\u65b0\u7684\u5206\u652f\u5f8c\u7528\u4f86\u6aa2\u8996
+gb.mergingViaCommandLine = \u7d93\u7531\u6307\u4ee4\u57f7\u884c\u5408\u4f75
+gb.mergingViaCommandLineNote = \u5982\u679c\u4f60\u4e0d\u60f3\u8981\u4f7f\u7528\u81ea\u52d5\u5408\u4f75\u529f\u80fd,\u6216\u662f\u6309\u4e0b\u5408\u4f75\u6309\u9215, \u4f60\u53ef\u4ee5\u4e0b\u6307\u4ee4\u624b\u52d5\u5408\u4f75
+gb.mergeStep1 = Check out a new branch to review the changes \u2014 run this from your project directory
+gb.mergeStep2 = Bring in the proposed changes and review
+gb.mergeStep3 = \u5c07\u63d0\u6848\u4fee\u6539\u5167\u5bb9\u66f4\u65b0\u5230\u4f3a\u670d\u5668\u4e0a
+gb.download = \u4e0b\u8f09
+gb.ptDescription = Gitblit \u88dc\u4e01\u5de5\u5177
+gb.ptCheckout = Fetch & checkout the current patchset to a review branch
+gb.ptMerge = \u53d6\u5f97\u76ee\u524dpatchset,\u7136\u5f8c\u8207\u4f60\u672c\u6a5f\u7aef\u7684\u5206\u652f\u5408\u4f75
+gb.ptDescription1 = Barnum is a command-line companion for Git that simplifies the syntax for working with Gitblit Tickets and Patchsets.
+gb.ptSimplifiedCollaboration = simplified collaboration syntax
+gb.ptSimplifiedMerge = simplified merge syntax
+gb.ptDescription2 = Barnum requires Python 3 and native Git. It runs on Windows, Linux, and Mac OS X.
+gb.stepN = \u6b65\u9a5f{0}
+gb.watchers = \u76e3\u7763\u8005
+gb.votes = \u6295\u7968
 gb.vote = \u5c0d{0}\u6295\u7968
-gb.voters = votes
-gb.votes = votes
-gb.warning = \u8b66\u544a
 gb.watch = \u76e3\u770b{0}
-gb.watchers = \u76e3\u770b\u8005
+gb.removeVote = \u79fb\u9664\u6295\u7968
+gb.stopWatching = \u505c\u6b62\u8ffd\u8e64(watching)
 gb.watching = \u76e3\u770b\u4e2d
-gb.workingCopy = \u5de5\u4f5c\u8907\u672c
-gb.workingCopyWarning = \u8a72\u6587\u4ef6\u5eab\u4ecd\u6709\u5de5\u4f5c\u8907\u672c,\u56e0\u6b64\u7121\u6cd5\u63a5\u53d7\u63a8\u9001(push)
-gb.write = write
-gb.youDoNotHaveClonePermission = \u4f60\u4e0d\u5141\u8a31\u8907\u88fd(clone)\u6b64\u6587\u4ef6\u5eab
+gb.comments = \u8a3b\u89e3
+gb.addComment = \u65b0\u589e\u8a3b\u89e3
+gb.export = \u532f\u51fa
+gb.oneCommit = 1\u500b\u63d0\u4ea4
+gb.nCommits = {0}\u4efd\u63d0\u4ea4
+gb.addedOneCommit = \u63d0\u4ea41\u500b\u6a94\u6848
+gb.addedNCommits = {0}\u500b\u6a94\u6848\u63d0\u4ea4\u5b8c\u7562
+gb.commitsInPatchsetN = \u88dc\u4e01 {0} \u7684\u63d0\u4ea4
+gb.patchsetN = \u88dc\u4e01{0}
+gb.reviewedPatchsetRev = reviewed patchset {0} revision {1}\: {2}
+gb.review = \u6aa2\u67e5(review)
+gb.reviews = \u6aa2\u67e5(reviews)
+gb.veto = \u5426\u6c7a
+gb.needsImprovement = \u9700\u8981\u512a\u5316
+gb.looksGood = \u770b\u8d77\u4f86\u5f88\u597d
+gb.approve = \u901a\u904e
+gb.hasNotReviewed = \u5c1a\u672a\u6aa2\u6838\u904e
+gb.about = \u95dc\u65bc
+gb.ticketN = \u4efb\u52d9\u7de8\u865f#{0}
+gb.disableUser = \u505c\u7528\u5e33\u6236
+gb.disableUserDescription = \u8a72\u5e33\u6236\u7121\u6cd5\u4f7f\u7528
+gb.any = \u4efb\u4f55
+gb.milestoneProgress = {0}\u958b\u555f,{1}\u7d50\u675f
+gb.nOpenTickets = {0}\u9805\u958b\u555f\u4e2d
+gb.nClosedTickets = {0}\u9805\u7d50\u675f
+gb.nTotalTickets = \u7e3d\u5171{0}\u9805
+gb.body = \u5167\u5bb9
+gb.mergeSha = mergeSha
+gb.mergeTo = \u5408\u4f75\u5230
+gb.labels = \u6a19\u8a18
+gb.reviewers = \u5be9\u67e5\u8005
+gb.voters = votes
+gb.mentions = \u63d0\u5230
+gb.canNotProposePatchset = \u4e0d\u80fd\u63d0\u4f9b\u88dc\u4e01
+gb.repositoryIsMirror = \u8a72\u7248\u672c\u5eab\u70ba\u552f\u8b80\u8907\u672c
+gb.repositoryIsFrozen = \u8a72\u7248\u672c\u5eab\u5df2\u51cd\u7d50
+gb.repositoryDoesNotAcceptPatchsets = \u8a72\u7248\u672c\u5eab\u4e0d\u63a5\u53d7\u88dc\u4e01
+gb.serverDoesNotAcceptPatchsets = \u672c\u4f3a\u670d\u5668\u4e0d\u63a5\u53d7\u88dc\u4e01
+gb.ticketIsClosed = \u8a72\u4efb\u52d9\u5df2\u7d93\u7d50\u6848
+gb.mergeToDescription = \u9810\u8a2d\u5c07\u6587\u4ef6\u76f8\u95dc\u88dc\u4e01\u5305\u8207\u6307\u5b9a\u5206\u652f(branch)\u5408\u4f75
+gb.anonymousCanNotPropose = \u533f\u540d\u8005\u4e0d\u80fd\u63d0\u4f9b\u88dc\u4e01
+gb.youDoNotHaveClonePermission = \u4f60\u4e0d\u5141\u8a31\u8907\u88fd(clone)\u6b64\u7248\u672c\u5eab
+gb.myTickets = \u6211\u7684\u4efb\u52d9
 gb.yourAssignedTickets = \u6307\u6d3e\u7d66\u4f60\u7684
-gb.yourCreatedTickets = \u7531\u4f60\u65b0\u589e\u7684
-gb.yourWatchedTickets = \u4f60\u60f3\u770b\u7684
-gb.zip = zip\u58d3\u7e2e\u6a94
-gb.ticketState =
-gb.repositoryForkFailed =
-gb.anonymousUser =
-gb.oneAttachment =
-gb.viewPolicy =
-gb.emailMeOnMyTicketChangesDescription =
+gb.newMilestone = \u5efa\u7acbmilestone
+gb.editMilestone = \u4fee\u6539milestone
+gb.deleteMilestone = \u522a\u9664milestone"{0}"?
+gb.milestoneDeleteFailed = \u522a\u9664milestone"{0}"\u5931\u6557
+gb.notifyChangedOpenTickets = \u5df2\u958b\u555f\u7684\u4efb\u52d9\u6709\u7570\u52d5\u8acb\u767c\u9001\u901a\u77e5
+gb.overdue = \u904e\u671f
+gb.openMilestones = \u5df2\u958b\u555f\u7684 milestones
+gb.closedMilestones = \u5df2\u95dc\u9589\u7684 milestones
+gb.administration = \u7ba1\u7406\u6b0a\u9650
+gb.plugins = \u5957\u4ef6
+gb.extensions = \u64f4\u5145
+gb.pleaseSelectProject = \u8acb\u9078\u64c7\u5c08\u6848!
+gb.accessPolicy = \u5b58\u53d6\u653f\u7b56
+gb.accessPolicyDescription = \u9078\u64c7\u7528\u4f86\u63a7\u5236\u7248\u672c\u5eab\u7684\u5b58\u53d6\u653f\u7b56\u4ee5\u53ca\u6b0a\u9650\u8a2d\u5b9a
+gb.anonymousPolicy = \u533f\u540d\u72c0\u614b\u53ef\u4ee5\u6aa2\u8996(view),\u8907\u88fd(clone)\u8207\u63a8\u9001(push)
+gb.anonymousPolicyDescription = \u6240\u6709\u4eba\u7686\u53ef\u6aa2\u8996(view),\u8907\u88fd(clone)\u8207\u63a8\u9001(push)\u6587\u4ef6\u5230\u7248\u672c\u5eab
+gb.authenticatedPushPolicy = \u9650\u5236\u63a8\u9001(Push)(\u6388\u6b0a)
+gb.authenticatedPushPolicyDescription = \u6240\u6709\u4eba\u7686\u53ef\u6aa2\u8996\u8207\u8907\u88fd(clone).  \u4f46\u53ea\u6709\u6210\u54e1\u6709RW+\u8207\u63a8\u9001(push)\u529f\u80fd.
+gb.namedPushPolicy = \u9650\u5236\u63a8\u9001(Push)(\u6307\u5b9a\u5e33\u865f)
+gb.namedPushPolicyDescription = \u6240\u6709\u4eba\u7686\u53ef\u6aa2\u8996\u8207\u8907\u88fd(clone)\u7248\u672c\u5eab.  \u53ef\u53e6\u5916\u6307\u5b9a\u8ab0\u80fd\u5920\u6709\u63a8\u9001\u529f\u80fd(push)
+gb.clonePolicy = \u9650\u5236\u8907\u88fd(Clone)\u8207\u63a8\u9001(Push)
+gb.clonePolicyDescription = \u6240\u6709\u4eba\u7686\u53ef\u770b\u7248\u672c\u5eab. \u53ef\u53e6\u5916\u6307\u5b9a\u8ab0\u6709\u8907\u88fd(clone)\u8207\u63a8\u9001(push)\u6b0a\u9650
+gb.viewPolicy = \u9650\u5236\u6aa2\u8996(view),\u8907\u88fd(clone)\u8207\u63a8\u9001(push)
+gb.viewPolicyDescription = \u9078\u64c7\u53ef\u4ee5\u5728\u7248\u672c\u5eab\u6aa2\u8996,\u8907\u88fd(clone)\u8207\u63a8\u9001(push)\u7684\u4f7f\u7528\u8005,  \u9664\u6b64\u4e4b\u5916\u5176\u4ed6\u4eba\u7686\u7121\u6b0a\u9650
+gb.initialCommit = \u521d\u6b21\u63d0\u4ea4
+gb.initialCommitDescription = \u4ee5\u4e0b\u6b65\u9a5f\u5c07\u99ac\u4e0a\u57f7\u884c<code>git clone</code>.\u5982\u679c\u4f60\u672c\u6a5f\u5df2\u6709\u6b64\u7248\u672c\u5eab\u4e14\u57f7\u884c\u904e<code>git init</code>,\u8acb\u8df3\u904e\u6b64\u6b65\u9a5f.
+gb.initWithReadme = \u5305\u542bREADME\u6587\u4ef6
+gb.initWithReadmeDescription = \u7248\u672c\u5eab\u5c07\u7522\u751f\u7c21\u55aeREADME\u6587\u4ef6
+gb.initWithGitignore = \u5305\u542b .gitignore \u6a94\u6848
+gb.initWithGitignoreDescription = \u65b0\u589e\u4e00\u500b\u8a2d\u5b9a\u6a94\u7528\u4f86\u6307\u5b9a\u54ea\u4e9b\u6a94\u6848\u6216\u76ee\u9304\u9700\u8981\u5ffd\u7565
+gb.pleaseSelectGitIgnore = \u8acb\u9078\u64c7\u4e00\u500b.gitignore\u6a94\u6848
+gb.receive = \u63a5\u6536
+gb.permissions = \u6b0a\u9650
+gb.ownersDescription = \u6240\u6709\u8005\u53ef\u4ee5\u7ba1\u7406\u7248\u672c\u5eab,\u4f46\u662f\u4e0d\u5141\u8a31\u4fee\u6539\u540d\u7a31(\u500b\u4eba\u7248\u672c\u5eab\u4f8b\u5916)
+gb.userPermissionsDescription = \u4f60\u53ef\u4ee5\u91dd\u5c0d\u5e33\u865f\u8a2d\u5b9a\u6b0a\u9650(\u9019\u4e9b\u8a2d\u5b9a\u5c07\u8986\u84cb\u5718\u968a\u6216\u5176\u4ed6\u6b0a\u9650)
+gb.teamPermissionsDescription = \u4f60\u53ef\u4ee5\u6307\u5b9a\u5718\u968a\u6b0a\u9650.\u9019\u4e9b\u8a2d\u5b9a\u5c07\u6703\u53d6\u4ee3\u539f\u672c\u5718\u968a\u9810\u8a2d\u6b0a\u9650
+gb.ticketSettings = \u4efb\u52d9\u5167\u5bb9\u8a2d\u5b9a
+gb.receiveSettings = \u8a2d\u5b9a\u63a5\u6536\u65b9\u5f0f
+gb.receiveSettingsDescription = \u63a7\u7ba1\u63a8\u9001\u5230\u7248\u672c\u5eab\u7684\u63a5\u6536\u65b9\u5f0f
+gb.preReceiveDescription = \u63a5\u5230\u63d0\u4ea4\u7533\u8acb\u5f8c,<em>\u4f46\u5728\u9084\u6c92\u6709\u66f4\u65b0refs\u524d</em>, \u5c07\u6703\u57f7\u884cPre-receive hook. <p>This is the appropriate hook for rejecting a push.</p>
+gb.postReceiveDescription = \u63a5\u5230\u63d0\u4ea4\u7533\u8acb\u5f8c,<em>\u4e26\u4e14\u5728refs\u5b8c\u7562\u5f8c</em>, \u5c07\u6703\u57f7\u884cPost-receive hook..<p>This is the appropriate hook for notifications, build triggers, etc.</p>
+gb.federationStrategyDescription = \u63a7\u5236\u5982\u4f55\u5c07\u7248\u672c\u5eab\u8207\u5176\u4ed6Gitblit\u7248\u63a7\u4f3a\u670d\u5668\u4e32\u9023
+gb.federationSetsDescription = \u6b64\u7248\u672c\u5eab\u5c07\u5305\u542b\u65bc\u6307\u5b9a\u7684federation sets
+gb.miscellaneous = \u5176\u4ed6
+gb.originDescription = \u6b64\u7248\u672c\u5eabURL\u5df2\u7d93\u88ab\u8907\u88fd(cloned)\u4e86
+gb.gc = \u7cfb\u7d71\u8cc7\u6e90\u56de\u6536\u5668
+gb.garbageCollection = \u56de\u6536\u7cfb\u7d71\u8cc7\u6e90
+gb.garbageCollectionDescription = \u7cfb\u7d71\u8cc7\u6e90\u56de\u6536\u529f\u80fd\u5c07\u6703\u6574\u9813\u9b06\u6563\u7528\u6236\u7aef\u63a8\u9001(push)\u7684\u7269\u4ef6, \u4e5f\u6703\u79fb\u9664\u7248\u672c\u5eab\u4e0a\u7121\u7528\u7684\u7269\u4ef6
+gb.commitMessageRendererDescription = \u63d0\u4ea4\u8a0a\u606f\u53ef\u4ee5\u4f7f\u7528\u6587\u5b57\u6216\u662f\u6a19\u8a18\u8a9e\u8a00(markup)\u5448\u73fe
+gb.preferences = \u9810\u8a2d\u5e38\u7528\u503c
+gb.accountPreferences = \u5e33\u865f\u8a2d\u5b9a
+gb.accountPreferencesDescription = \u8a2d\u5b9a\u5e33\u865f\u9810\u8a2d\u503c
+gb.languagePreference = \u5e38\u7528\u8a9e\u8a00
+gb.languagePreferenceDescription = \u9078\u64c7\u8a9e\u7cfb
+gb.emailMeOnMyTicketChanges = \u4efb\u52d9\u82e5\u6709\u8b8a\u66f4,\u8acb\u7acb\u5373(email)\u901a\u77e5\u6211
+gb.emailMeOnMyTicketChangesDescription =\u6211\u8655\u7406\u904e\u7684\u4efb\u52d9\u8acbemail\u901a\u77e5\u6211
+gb.displayNameDescription = \u5e0c\u671b\u986f\u793a\u7684\u540d\u7a31
+gb.emailAddressDescription = \u7528\u4f86\u63a5\u6536\u901a\u77e5\u7684\u4e3b\u8981\u96fb\u5b50\u90f5\u4ef6
+gb.sshKeys = SSH Keys
+gb.sshKeysDescription = SSH \u516c\u958b\u91d1\u9470\u662f\u5bc6\u78bc\u8a8d\u8b49\u5916\u66f4\u5b89\u5168\u7684\u9078\u9805
+gb.addSshKey = \u65b0\u589e SSH Key
+gb.key = \u91d1\u9470
+gb.sshKeyCommentDescription = \u8acb\u8f38\u5165\u5099\u8a3b, \u82e5\u7121\u5099\u8a3b, \u5c07\u81ea\u8a02\u586b\u5165key data
+gb.sshKeyPermissionDescription = \u6307\u5b9a\u8a72SSH key\u6240\u64c1\u6709\u7684\u5b58\u53d6\u6b0a\u9650
+gb.transportPreference = \u9810\u8a2d\u901a\u8a0a\u5354\u5b9a
+gb.transportPreferenceDescription = \u8a2d\u5b9a\u4f60\u5e38\u7528\u7684\u9023\u7dda\u7528\u4f86\u8907\u88fd(clone)
+gb.blinkComparator = Blink comparator
+gb.deleteRepositoryDescription = \u7248\u672c\u5eab\u522a\u9664\u5c07\u7121\u6cd5\u9084\u539f
+gb.deleteRepositoryHeader = \u522a\u9664\u7248\u672c\u5eab
+gb.diffCopiedFile = \u6a94\u6848\u7531 {0} \u8907\u88fd
+gb.diffDeletedFile = \u6a94\u6848\u5df2\u522a\u9664
+gb.diffDeletedFileSkipped = (\u522a\u9664)
+gb.diffFileDiffTooLarge = \u6a94\u6848\u592a\u5927
+gb.diffNewFile = \u6bd4\u5c0d\u65b0\u6a94\u6848
+gb.diffRenamedFile = \u6a94\u540d\u7531 {0} \u4fee\u6539
+gb.diffTruncated = Diff truncated after the above file
+gb.ignore_whitespace =\u5ffd\u7565\u7a7a\u767d
+gb.imgdiffSubtract = Subtract (black = identical)
+gb.maintenanceTickets = \u7dad\u8b77
+gb.missingIntegrationBranchMore = \u76ee\u6a19\u5206\u652f\u4e0d\u5728\u6b64\u7248\u672c\u5eab
+gb.opacityAdjust = Adjust opacity
+gb.priority = \u512a\u5148
+gb.severity = \u91cd\u8981
+gb.show_whitespace = \u986f\u793a\u7a7a\u767d
+gb.sortHighestPriority = \u6700\u9ad8
+gb.sortHighestSeverity = \u6700\u91cd\u8981
+gb.sortLowestPriority = \u6700\u4f4e
+gb.sortLowestSeverity = \u6700\u4e0d\u91cd\u8981
+gb.ticketStatus = \u72c0\u614b
+gb.allRepositories = \u6240\u6709\u7248\u672c\u5eab
+gb.oid = \u7de8\u865f
+gb.filestore = \u5927\u6a94\u6848\u5340
+gb.filestoreStats = \u5927\u6a94\u6848\u5340(Filestore)\u5305\u542b {0} \u6a94\u6848\u5bb9\u91cf {1}.  (\u9084\u5269\u4e0b{2}\u53ef\u7528)
+gb.statusChangedOn = \u4fee\u6539\u65e5\u671f
+gb.statusChangedBy = \u4fee\u6539\u8005
+gb.filestoreHelp = \u6309\u6b64\u4e86\u89e3\u5927\u6a94\u6848\u5340(FileStore)\u5132\u5b58\u529f\u80fd
+gb.editFile = \u7de8\u8f2f\u6a94\u6848
+gb.continueEditing = \u7e7c\u7e8c\u7de8\u8f2f
+gb.commitChanges = Commit Changes
+gb.fileNotMergeable = \u7121\u6cd5\u63d0\u4ea4 {0}.  \u6a94\u6848\u7121\u6cd5\u81ea\u52d5\u5408\u4f75.
+gb.fileCommitted = \u6210\u529f\u63d0\u4ea4
+gb.deletePatchset = \u522a\u9664 Patchset {0}
+gb.deletePatchsetSuccess = \u5df2\u522a\u9664 Patchset {0}.
+gb.deletePatchsetFailure = \u522a\u9664 Patchset {0} \u932f\u8aa4.
+gb.referencedByCommit = Referenced by commit.
+gb.referencedByTicket = Referenced by ticket.
diff --git a/src/main/java/com/gitblit/wicket/pages/BasePage.html b/src/main/java/com/gitblit/wicket/pages/BasePage.html
index b998428..4dbc2e5 100644
--- a/src/main/java/com/gitblit/wicket/pages/BasePage.html
+++ b/src/main/java/com/gitblit/wicket/pages/BasePage.html
@@ -17,6 +17,7 @@
 		<link rel="stylesheet" href="fontawesome/css/font-awesome.min.css"/>

         <link rel="stylesheet" href="octicons/octicons.css"/>

 		<link rel="stylesheet" type="text/css" href="gitblit.css"/>

+		<link rel="stylesheet" type="text/css" href="bootstrap-fixes.css"/>

 	</wicket:head>

 

 	<body>

diff --git a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html
index 7a55b9f..2c881ef 100644
--- a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html
+++ b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html
@@ -123,7 +123,8 @@
 			<div wicket:id="acceptNewTickets"></div>

 			<div wicket:id="requireApproval"></div>

 			<div wicket:id="mergeTo"></div>

-		

+			<div wicket:id="mergeType"></div>

+

 		</div>

 				

 		<!-- federation -->

diff --git a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java
index 6bcf6f5..bf3eea8 100644
--- a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java
+++ b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java
@@ -56,6 +56,7 @@
 import com.gitblit.Constants.AuthorizationControl;

 import com.gitblit.Constants.CommitMessageRenderer;

 import com.gitblit.Constants.FederationStrategy;

+import com.gitblit.Constants.MergeType;

 import com.gitblit.Constants.RegistrantType;

 import com.gitblit.GitBlitException;

 import com.gitblit.Keys;

@@ -458,6 +459,11 @@
 				getString("gb.mergeToDescription"),

 				new PropertyModel<String>(repositoryModel, "mergeTo"),

 				availableBranches));

+		form.add(new ChoiceOption<MergeType>("mergeType",

+				getString("gb.mergeType"),

+				getString("gb.mergeTypeDescription"),

+				new PropertyModel<MergeType>(repositoryModel, "mergeType"),

+				Arrays.asList(MergeType.values())));

 

 		//

 		// RECEIVE

diff --git a/src/main/java/com/gitblit/wicket/pages/EditUserPage.java b/src/main/java/com/gitblit/wicket/pages/EditUserPage.java
index 220bee3..72dee6b 100644
--- a/src/main/java/com/gitblit/wicket/pages/EditUserPage.java
+++ b/src/main/java/com/gitblit/wicket/pages/EditUserPage.java
@@ -156,7 +156,7 @@
 						}

 

 						// change the cookie

-						userModel.cookie = StringUtils.getSHA1(userModel.username + password);

+						userModel.cookie = userModel.createCookie();

 

 						// Optionally store the password MD5 digest.

 						String type = app().settings().getString(Keys.realm.passwordStorage, "md5");

diff --git a/src/main/java/com/gitblit/wicket/pages/TicketPage.java b/src/main/java/com/gitblit/wicket/pages/TicketPage.java
index cd049f4..e213396 100644
--- a/src/main/java/com/gitblit/wicket/pages/TicketPage.java
+++ b/src/main/java/com/gitblit/wicket/pages/TicketPage.java
@@ -1405,14 +1405,14 @@
 

 		boolean allowMerge;

 		if (repository.requireApproval) {

-			// rpeository requires approval

+			// repository requires approval

 			allowMerge = ticket.isOpen() && ticket.isApproved(patchset);

 		} else {

-			// vetos are binding

+			// vetoes are binding

 			allowMerge = ticket.isOpen() && !ticket.isVetoed(patchset);

 		}

 

-		MergeStatus mergeStatus = JGitUtils.canMerge(getRepository(), patchset.tip, ticket.mergeTo);

+		MergeStatus mergeStatus = JGitUtils.canMerge(getRepository(), patchset.tip, ticket.mergeTo, repository.mergeType);

 		if (allowMerge) {

 			if (MergeStatus.MERGEABLE == mergeStatus) {

 				// patchset can be cleanly merged to integration branch OR has already been merged

diff --git a/src/main/java/com/gitblit/wicket/panels/PagerPanel.java b/src/main/java/com/gitblit/wicket/panels/PagerPanel.java
index 2d774c4..d1214ca 100644
--- a/src/main/java/com/gitblit/wicket/panels/PagerPanel.java
+++ b/src/main/java/com/gitblit/wicket/panels/PagerPanel.java
@@ -48,7 +48,7 @@
 			deltas = new int[] { -2, -1, 0, 1, 2 };

 		}

 

-		if (totalPages > 0) {

+		if (totalPages > 0 && currentPage > 1) {

 			pages.add(new PageObject("\u2190", currentPage - 1));

 		}

 		for (int delta : deltas) {

@@ -57,7 +57,7 @@
 				pages.add(new PageObject("" + page, page));

 			}

 		}

-		if (totalPages > 0) {

+		if (totalPages > 0 && currentPage < totalPages) {

 			pages.add(new PageObject("\u2192", currentPage + 1));

 		}

 

@@ -75,6 +75,7 @@
 				item.add(link);

 				if (pageItem.page == currentPage || pageItem.page < 1 || pageItem.page > totalPages) {

 					WicketUtils.setCssClass(item, "disabled");

+					link.setEnabled(false);

 				}

 			}

 		};

diff --git a/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java b/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java
index 15ebd67..4b87876 100644
--- a/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java
+++ b/src/main/java/com/gitblit/wicket/panels/SshKeysPanel.java
@@ -48,11 +48,13 @@
 	private static final long serialVersionUID = 1L;

 

 	private final UserModel user;

+	private final boolean canWriteKeys;

 

 	public SshKeysPanel(String wicketId, UserModel user) {

 		super(wicketId);

 

 		this.user = user;

+		this.canWriteKeys = app().keys().supportsWritingKeys(user);

 	}

 

 	@Override

@@ -90,6 +92,9 @@
 						}

 					}

 				};

+				if (!canWriteKeys) {

+					delete.setVisibilityAllowed(false);

+				}

 				item.add(delete);

 			}

 		};

@@ -164,6 +169,10 @@
 			}

 		});

 

+		if (! canWriteKeys) {

+			addKeyForm.setVisibilityAllowed(false);

+		}

+

 		add(addKeyForm);

 	}

 }

diff --git a/src/main/java/welcome_zh_TW.mkd b/src/main/java/welcome_zh_TW.mkd
index eaaee65..ec10201 100644
--- a/src/main/java/welcome_zh_TW.mkd
+++ b/src/main/java/welcome_zh_TW.mkd
@@ -1,3 +1,3 @@
 ## 歡迎來到Gitblit版本控管伺服器
 
-一個快速讓您能存放自己Git文件庫的解決方案  [Git](http://www.git-scm.com) 
+一個快速讓您擁有版本控管伺服器的解決方案 : 使用[Git](http://www.git-scm.com) 
diff --git a/src/main/resources/bootstrap-fixes.css b/src/main/resources/bootstrap-fixes.css
new file mode 100644
index 0000000..c9b6154
--- /dev/null
+++ b/src/main/resources/bootstrap-fixes.css
@@ -0,0 +1,25 @@
+/**
+ * Disabled links in a PagerPanel. Bootstrap 2.0.4 only handles <a>, but not <span>. Wicket renders disabled links as spans.
+ * The .pagination rules here are identical to the ones for <a> in bootstrap.css, but for <span>.
+ */
+.pagination span {
+  float: left;
+  padding: 0 14px;
+  line-height: 34px;
+  text-decoration: none;
+  border: 1px solid #ddd;
+  border-left-width: 0;
+}
+
+.pagination li:first-child span {
+  border-left-width: 1px;
+  -webkit-border-radius: 3px 0 0 3px;
+     -moz-border-radius: 3px 0 0 3px;
+          border-radius: 3px 0 0 3px;
+}
+
+.pagination li:last-child span {
+  -webkit-border-radius: 0 3px 3px 0;
+     -moz-border-radius: 0 3px 3px 0;
+          border-radius: 0 3px 3px 0;
+}
diff --git a/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java b/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java
index 84dd138..4f79edf 100644
--- a/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java
+++ b/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java
@@ -16,18 +16,12 @@
  */
 package com.gitblit.tests;
 
-import java.io.File;
-import java.io.FileInputStream;
-import java.util.HashMap;
-import java.util.Map;
+import static org.junit.Assume.*;
 
-import org.apache.commons.io.FileUtils;
 import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.TemporaryFolder;
-
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
 import com.gitblit.Constants.AccountType;
 import com.gitblit.IStoredSettings;
 import com.gitblit.Keys;
@@ -41,9 +35,6 @@
 import com.gitblit.tests.mock.MemorySettings;
 import com.gitblit.utils.XssFilter;
 import com.gitblit.utils.XssFilter.AllowXssFilter;
-import com.unboundid.ldap.listener.InMemoryDirectoryServer;
-import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
-import com.unboundid.ldap.listener.InMemoryListenerConfig;
 import com.unboundid.ldap.sdk.SearchResult;
 import com.unboundid.ldap.sdk.SearchScope;
 import com.unboundid.ldif.LDIFReader;
@@ -55,44 +46,18 @@
  * @author jcrygier
  *
  */
-public class LdapAuthenticationTest extends GitblitUnitTest {
-    @Rule
-    public TemporaryFolder folder = new TemporaryFolder();
+@RunWith(Parameterized.class)
+public class LdapAuthenticationTest extends LdapBasedUnitTest {
 
-	private static final String RESOURCE_DIR = "src/test/resources/ldap/";
-
-    private File usersConf;
-
-    private LdapAuthProvider ldap;
-
-	static int ldapPort = 1389;
-
-	private static InMemoryDirectoryServer ds;
+	private LdapAuthProvider ldap;
 
 	private IUserManager userManager;
 
 	private AuthenticationManager auth;
 
-	private MemorySettings settings;
-
-	@BeforeClass
-	public static void createInMemoryLdapServer() throws Exception {
-		InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=MyDomain");
-		config.addAdditionalBindCredentials("cn=Directory Manager", "password");
-		config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", ldapPort));
-		config.setSchema(null);
-
-		ds = new InMemoryDirectoryServer(config);
-		ds.startListening();
-	}
 
 	@Before
-	public void init() throws Exception {
-		ds.clear();
-		ds.importFromLDIF(true, new LDIFReader(new FileInputStream(RESOURCE_DIR + "sampledata.ldif")));
-		usersConf = folder.newFile("users.conf");
-		FileUtils.copyFile(new File(RESOURCE_DIR + "users.conf"), usersConf);
-		settings = getSettings();
+	public void setup() throws Exception {
 		ldap = newLdapAuthentication(settings);
 		auth = newAuthenticationManager(settings);
 	}
@@ -114,35 +79,12 @@
 		return auth;
 	}
 
-	private MemorySettings getSettings() {
-		Map<String, Object> backingMap = new HashMap<String, Object>();
-		backingMap.put(Keys.realm.userService, usersConf.getAbsolutePath());
-		backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + ldapPort);
-//		backingMap.put(Keys.realm.ldap.domain, "");
-		backingMap.put(Keys.realm.ldap.username, "cn=Directory Manager");
-		backingMap.put(Keys.realm.ldap.password, "password");
-//		backingMap.put(Keys.realm.ldap.backingUserService, "users.conf");
-		backingMap.put(Keys.realm.ldap.maintainTeams, "true");
-		backingMap.put(Keys.realm.ldap.accountBase, "OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain");
-		backingMap.put(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))");
-		backingMap.put(Keys.realm.ldap.groupBase, "OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain");
-		backingMap.put(Keys.realm.ldap.groupMemberPattern, "(&(objectClass=group)(member=${dn}))");
-		backingMap.put(Keys.realm.ldap.admins, "UserThree @Git_Admins \"@Git Admins\"");
-		backingMap.put(Keys.realm.ldap.displayName, "displayName");
-		backingMap.put(Keys.realm.ldap.email, "email");
-		backingMap.put(Keys.realm.ldap.uid, "sAMAccountName");
-
-		MemorySettings ms = new MemorySettings(backingMap);
-		return ms;
-	}
-
 	@Test
 	public void testAuthenticate() {
 		UserModel userOneModel = ldap.authenticate("UserOne", "userOnePassword".toCharArray());
 		assertNotNull(userOneModel);
 		assertNotNull(userOneModel.getTeam("git_admins"));
 		assertNotNull(userOneModel.getTeam("git_users"));
-		assertTrue(userOneModel.canAdmin);
 
 		UserModel userOneModelFailedAuth = ldap.authenticate("UserOne", "userTwoPassword".toCharArray());
 		assertNull(userOneModelFailedAuth);
@@ -152,13 +94,101 @@
 		assertNotNull(userTwoModel.getTeam("git_users"));
 		assertNull(userTwoModel.getTeam("git_admins"));
 		assertNotNull(userTwoModel.getTeam("git admins"));
-		assertTrue(userTwoModel.canAdmin);
 
 		UserModel userThreeModel = ldap.authenticate("UserThree", "userThreePassword".toCharArray());
 		assertNotNull(userThreeModel);
 		assertNotNull(userThreeModel.getTeam("git_users"));
 		assertNull(userThreeModel.getTeam("git_admins"));
+
+		UserModel userFourModel = ldap.authenticate("UserFour", "userFourPassword".toCharArray());
+		assertNotNull(userFourModel);
+		assertNotNull(userFourModel.getTeam("git_users"));
+		assertNull(userFourModel.getTeam("git_admins"));
+		assertNull(userFourModel.getTeam("git admins"));
+	}
+
+	@Test
+	public void testAdminPropertyTeamsInLdap() {
+		UserModel userOneModel = ldap.authenticate("UserOne", "userOnePassword".toCharArray());
+		assertNotNull(userOneModel);
+		assertNotNull(userOneModel.getTeam("git_admins"));
+		assertNull(userOneModel.getTeam("git admins"));
+		assertNotNull(userOneModel.getTeam("git_users"));
+		assertFalse(userOneModel.canAdmin);
+		assertTrue(userOneModel.canAdmin());
+		assertTrue(userOneModel.getTeam("git_admins").canAdmin);
+		assertFalse(userOneModel.getTeam("git_users").canAdmin);
+
+		UserModel userTwoModel = ldap.authenticate("UserTwo", "userTwoPassword".toCharArray());
+		assertNotNull(userTwoModel);
+		assertNotNull(userTwoModel.getTeam("git_users"));
+		assertNull(userTwoModel.getTeam("git_admins"));
+		assertNotNull(userTwoModel.getTeam("git admins"));
+		assertFalse(userTwoModel.canAdmin);
+		assertTrue(userTwoModel.canAdmin());
+		assertTrue(userTwoModel.getTeam("git admins").canAdmin);
+		assertFalse(userTwoModel.getTeam("git_users").canAdmin);
+
+		UserModel userThreeModel = ldap.authenticate("UserThree", "userThreePassword".toCharArray());
+		assertNotNull(userThreeModel);
+		assertNotNull(userThreeModel.getTeam("git_users"));
+		assertNull(userThreeModel.getTeam("git_admins"));
+		assertNull(userThreeModel.getTeam("git admins"));
 		assertTrue(userThreeModel.canAdmin);
+		assertTrue(userThreeModel.canAdmin());
+		assertFalse(userThreeModel.getTeam("git_users").canAdmin);
+
+		UserModel userFourModel = ldap.authenticate("UserFour", "userFourPassword".toCharArray());
+		assertNotNull(userFourModel);
+		assertNotNull(userFourModel.getTeam("git_users"));
+		assertNull(userFourModel.getTeam("git_admins"));
+		assertNull(userFourModel.getTeam("git admins"));
+		assertFalse(userFourModel.canAdmin);
+		assertFalse(userFourModel.canAdmin());
+		assertFalse(userFourModel.getTeam("git_users").canAdmin);
+	}
+
+	@Test
+	public void testAdminPropertyTeamsNotInLdap() {
+		settings.put(Keys.realm.ldap.maintainTeams, "false");
+
+		UserModel userOneModel = ldap.authenticate("UserOne", "userOnePassword".toCharArray());
+		assertNotNull(userOneModel);
+		assertNotNull(userOneModel.getTeam("git_admins"));
+		assertNull(userOneModel.getTeam("git admins"));
+		assertNotNull(userOneModel.getTeam("git_users"));
+		assertTrue(userOneModel.canAdmin);
+		assertTrue(userOneModel.canAdmin());
+		assertFalse(userOneModel.getTeam("git_admins").canAdmin);
+		assertFalse(userOneModel.getTeam("git_users").canAdmin);
+
+		UserModel userTwoModel = ldap.authenticate("UserTwo", "userTwoPassword".toCharArray());
+		assertNotNull(userTwoModel);
+		assertNotNull(userTwoModel.getTeam("git_users"));
+		assertNull(userTwoModel.getTeam("git_admins"));
+		assertNotNull(userTwoModel.getTeam("git admins"));
+		assertFalse(userTwoModel.canAdmin);
+		assertTrue(userTwoModel.canAdmin());
+		assertTrue(userTwoModel.getTeam("git admins").canAdmin);
+		assertFalse(userTwoModel.getTeam("git_users").canAdmin);
+
+		UserModel userThreeModel = ldap.authenticate("UserThree", "userThreePassword".toCharArray());
+		assertNotNull(userThreeModel);
+		assertNotNull(userThreeModel.getTeam("git_users"));
+		assertNull(userThreeModel.getTeam("git_admins"));
+		assertNull(userThreeModel.getTeam("git admins"));
+		assertFalse(userThreeModel.canAdmin);
+		assertFalse(userThreeModel.canAdmin());
+		assertFalse(userThreeModel.getTeam("git_users").canAdmin);
+
+		UserModel userFourModel = ldap.authenticate("UserFour", "userFourPassword".toCharArray());
+		assertNotNull(userFourModel);
+		assertNotNull(userFourModel.getTeam("git_users"));
+		assertNull(userFourModel.getTeam("git_admins"));
+		assertNull(userFourModel.getTeam("git admins"));
+		assertFalse(userFourModel.canAdmin);
+		assertFalse(userFourModel.canAdmin());
+		assertFalse(userFourModel.getTeam("git_users").canAdmin);
 	}
 
 	@Test
@@ -204,13 +234,13 @@
 
 	@Test
 	public void checkIfUsersConfContainsAllUsersFromSampleDataLdif() throws Exception {
-		SearchResult searchResult = ds.search("OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain", SearchScope.SUB, "objectClass=person");
+		SearchResult searchResult = getDS().search(ACCOUNT_BASE, SearchScope.SUB, "objectClass=person");
 		assertEquals("Number of ldap users in gitblit user model", searchResult.getEntryCount(), countLdapUsersInUserManager());
 	}
 
 	@Test
 	public void addingUserInLdapShouldNotUpdateGitBlitUsersAndGroups() throws Exception {
-		ds.addEntries(LDIFReader.readEntries(RESOURCE_DIR + "adduser.ldif"));
+		getDS().addEntries(LDIFReader.readEntries(RESOURCE_DIR + "adduser.ldif"));
 		ldap.sync();
 		assertEquals("Number of ldap users in gitblit user model", 5, countLdapUsersInUserManager());
 	}
@@ -218,33 +248,126 @@
 	@Test
 	public void addingUserInLdapShouldUpdateGitBlitUsersAndGroups() throws Exception {
 		settings.put(Keys.realm.ldap.synchronize, "true");
-		ds.addEntries(LDIFReader.readEntries(RESOURCE_DIR + "adduser.ldif"));
+		getDS().addEntries(LDIFReader.readEntries(RESOURCE_DIR + "adduser.ldif"));
 		ldap.sync();
 		assertEquals("Number of ldap users in gitblit user model", 6, countLdapUsersInUserManager());
 	}
 
 	@Test
 	public void addingGroupsInLdapShouldNotUpdateGitBlitUsersAndGroups() throws Exception {
-		ds.addEntries(LDIFReader.readEntries(RESOURCE_DIR + "addgroup.ldif"));
+		getDS().addEntries(LDIFReader.readEntries(RESOURCE_DIR + "addgroup.ldif"));
 		ldap.sync();
 		assertEquals("Number of ldap groups in gitblit team model", 0, countLdapTeamsInUserManager());
 	}
 
 	@Test
-	public void addingGroupsInLdapShouldUpdateGitBlitUsersAndGroups() throws Exception {
+	public void addingGroupsInLdapShouldUpdateGitBlitUsersNotGroups2() throws Exception {
 		settings.put(Keys.realm.ldap.synchronize, "true");
-		ds.addEntries(LDIFReader.readEntries(RESOURCE_DIR + "addgroup.ldif"));
+		settings.put(Keys.realm.ldap.maintainTeams, "false");
+		getDS().addEntries(LDIFReader.readEntries(RESOURCE_DIR + "adduser.ldif"));
+		getDS().addEntries(LDIFReader.readEntries(RESOURCE_DIR + "addgroup.ldif"));
+		ldap.sync();
+		assertEquals("Number of ldap users in gitblit user model", 6, countLdapUsersInUserManager());
+		assertEquals("Number of ldap groups in gitblit team model", 0, countLdapTeamsInUserManager());
+	}
+
+	@Test
+	public void addingGroupsInLdapShouldUpdateGitBlitUsersAndGroups() throws Exception {
+		// This test only makes sense if the authentication mode allows for synchronization.
+		assumeTrue(authMode == AuthMode.ANONYMOUS || authMode == AuthMode.DS_MANAGER);
+
+		settings.put(Keys.realm.ldap.synchronize, "true");
+		getDS().addEntries(LDIFReader.readEntries(RESOURCE_DIR + "addgroup.ldif"));
 		ldap.sync();
 		assertEquals("Number of ldap groups in gitblit team model", 1, countLdapTeamsInUserManager());
 	}
 
 	@Test
+	public void syncUpdateUsersAndGroupsAdminProperty() throws Exception {
+		// This test only makes sense if the authentication mode allows for synchronization.
+		assumeTrue(authMode == AuthMode.ANONYMOUS || authMode == AuthMode.DS_MANAGER);
+
+		settings.put(Keys.realm.ldap.synchronize, "true");
+		ldap.sync();
+
+		UserModel user = userManager.getUserModel("UserOne");
+		assertNotNull(user);
+		assertFalse(user.canAdmin);
+		assertTrue(user.canAdmin());
+
+		user = userManager.getUserModel("UserTwo");
+		assertNotNull(user);
+		assertFalse(user.canAdmin);
+		assertTrue(user.canAdmin());
+
+		user = userManager.getUserModel("UserThree");
+		assertNotNull(user);
+		assertTrue(user.canAdmin);
+		assertTrue(user.canAdmin());
+
+		user = userManager.getUserModel("UserFour");
+		assertNotNull(user);
+		assertFalse(user.canAdmin);
+		assertFalse(user.canAdmin());
+
+		TeamModel team = userManager.getTeamModel("Git_Admins");
+		assertNotNull(team);
+		assertTrue(team.canAdmin);
+
+		team = userManager.getTeamModel("Git Admins");
+		assertNotNull(team);
+		assertTrue(team.canAdmin);
+
+		team = userManager.getTeamModel("Git_Users");
+		assertNotNull(team);
+		assertFalse(team.canAdmin);
+	}
+
+	@Test
+	public void syncNotUpdateUsersAndGroupsAdminProperty() throws Exception {
+		settings.put(Keys.realm.ldap.synchronize, "true");
+		settings.put(Keys.realm.ldap.maintainTeams, "false");
+		ldap.sync();
+
+		UserModel user = userManager.getUserModel("UserOne");
+		assertNotNull(user);
+		assertTrue(user.canAdmin);
+		assertTrue(user.canAdmin());
+
+		user = userManager.getUserModel("UserTwo");
+		assertNotNull(user);
+		assertFalse(user.canAdmin);
+		assertTrue(user.canAdmin());
+
+		user = userManager.getUserModel("UserThree");
+		assertNotNull(user);
+		assertFalse(user.canAdmin);
+		assertFalse(user.canAdmin());
+
+		user = userManager.getUserModel("UserFour");
+		assertNotNull(user);
+		assertFalse(user.canAdmin);
+		assertFalse(user.canAdmin());
+
+		TeamModel team = userManager.getTeamModel("Git_Admins");
+		assertNotNull(team);
+		assertFalse(team.canAdmin);
+
+		team = userManager.getTeamModel("Git Admins");
+		assertNotNull(team);
+		assertTrue(team.canAdmin);
+
+		team = userManager.getTeamModel("Git_Users");
+		assertNotNull(team);
+		assertFalse(team.canAdmin);
+	}
+
+	@Test
 	public void testAuthenticationManager() {
 		UserModel userOneModel = auth.authenticate("UserOne", "userOnePassword".toCharArray(), null);
 		assertNotNull(userOneModel);
 		assertNotNull(userOneModel.getTeam("git_admins"));
 		assertNotNull(userOneModel.getTeam("git_users"));
-		assertTrue(userOneModel.canAdmin);
 
 		UserModel userOneModelFailedAuth = auth.authenticate("UserOne", "userTwoPassword".toCharArray(), null);
 		assertNull(userOneModelFailedAuth);
@@ -254,18 +377,115 @@
 		assertNotNull(userTwoModel.getTeam("git_users"));
 		assertNull(userTwoModel.getTeam("git_admins"));
 		assertNotNull(userTwoModel.getTeam("git admins"));
-		assertTrue(userTwoModel.canAdmin);
 
 		UserModel userThreeModel = auth.authenticate("UserThree", "userThreePassword".toCharArray(), null);
 		assertNotNull(userThreeModel);
 		assertNotNull(userThreeModel.getTeam("git_users"));
 		assertNull(userThreeModel.getTeam("git_admins"));
+
+		UserModel userFourModel = auth.authenticate("UserFour", "userFourPassword".toCharArray(), null);
+		assertNotNull(userFourModel);
+		assertNotNull(userFourModel.getTeam("git_users"));
+		assertNull(userFourModel.getTeam("git_admins"));
+		assertNull(userFourModel.getTeam("git admins"));
+	}
+
+	@Test
+	public void testAuthenticationManagerAdminPropertyTeamsInLdap() {
+		UserModel userOneModel = auth.authenticate("UserOne", "userOnePassword".toCharArray(), null);
+		assertNotNull(userOneModel);
+		assertNotNull(userOneModel.getTeam("git_admins"));
+		assertNull(userOneModel.getTeam("git admins"));
+		assertNotNull(userOneModel.getTeam("git_users"));
+		assertFalse(userOneModel.canAdmin);
+		assertTrue(userOneModel.canAdmin());
+		assertTrue(userOneModel.getTeam("git_admins").canAdmin);
+		assertFalse(userOneModel.getTeam("git_users").canAdmin);
+
+		UserModel userOneModelFailedAuth = auth.authenticate("UserOne", "userTwoPassword".toCharArray(), null);
+		assertNull(userOneModelFailedAuth);
+
+		UserModel userTwoModel = auth.authenticate("UserTwo", "userTwoPassword".toCharArray(), null);
+		assertNotNull(userTwoModel);
+		assertNotNull(userTwoModel.getTeam("git_users"));
+		assertNull(userTwoModel.getTeam("git_admins"));
+		assertNotNull(userTwoModel.getTeam("git admins"));
+		assertFalse(userTwoModel.canAdmin);
+		assertTrue(userTwoModel.canAdmin());
+		assertTrue(userTwoModel.getTeam("git admins").canAdmin);
+		assertFalse(userTwoModel.getTeam("git_users").canAdmin);
+
+		UserModel userThreeModel = auth.authenticate("UserThree", "userThreePassword".toCharArray(), null);
+		assertNotNull(userThreeModel);
+		assertNotNull(userThreeModel.getTeam("git_users"));
+		assertNull(userThreeModel.getTeam("git_admins"));
+		assertNull(userThreeModel.getTeam("git admins"));
 		assertTrue(userThreeModel.canAdmin);
+		assertTrue(userThreeModel.canAdmin());
+		assertFalse(userThreeModel.getTeam("git_users").canAdmin);
+
+		UserModel userFourModel = auth.authenticate("UserFour", "userFourPassword".toCharArray(), null);
+		assertNotNull(userFourModel);
+		assertNotNull(userFourModel.getTeam("git_users"));
+		assertNull(userFourModel.getTeam("git_admins"));
+		assertNull(userFourModel.getTeam("git admins"));
+		assertFalse(userFourModel.canAdmin);
+		assertFalse(userFourModel.canAdmin());
+		assertFalse(userFourModel.getTeam("git_users").canAdmin);
+	}
+
+	@Test
+	public void testAuthenticationManagerAdminPropertyTeamsNotInLdap() {
+		settings.put(Keys.realm.ldap.maintainTeams, "false");
+
+		UserModel userOneModel = auth.authenticate("UserOne", "userOnePassword".toCharArray(), null);
+		assertNotNull(userOneModel);
+		assertNotNull(userOneModel.getTeam("git_admins"));
+		assertNull(userOneModel.getTeam("git admins"));
+		assertNotNull(userOneModel.getTeam("git_users"));
+		assertTrue(userOneModel.canAdmin);
+		assertTrue(userOneModel.canAdmin());
+		assertFalse(userOneModel.getTeam("git_admins").canAdmin);
+		assertFalse(userOneModel.getTeam("git_users").canAdmin);
+
+		UserModel userOneModelFailedAuth = auth.authenticate("UserOne", "userTwoPassword".toCharArray(), null);
+		assertNull(userOneModelFailedAuth);
+
+		UserModel userTwoModel = auth.authenticate("UserTwo", "userTwoPassword".toCharArray(), null);
+		assertNotNull(userTwoModel);
+		assertNotNull(userTwoModel.getTeam("git_users"));
+		assertNull(userTwoModel.getTeam("git_admins"));
+		assertNotNull(userTwoModel.getTeam("git admins"));
+		assertFalse(userTwoModel.canAdmin);
+		assertTrue(userTwoModel.canAdmin());
+		assertTrue(userTwoModel.getTeam("git admins").canAdmin);
+		assertFalse(userTwoModel.getTeam("git_users").canAdmin);
+
+		UserModel userThreeModel = auth.authenticate("UserThree", "userThreePassword".toCharArray(), null);
+		assertNotNull(userThreeModel);
+		assertNotNull(userThreeModel.getTeam("git_users"));
+		assertNull(userThreeModel.getTeam("git_admins"));
+		assertNull(userThreeModel.getTeam("git admins"));
+		assertFalse(userThreeModel.canAdmin);
+		assertFalse(userThreeModel.canAdmin());
+		assertFalse(userThreeModel.getTeam("git_users").canAdmin);
+
+		UserModel userFourModel = auth.authenticate("UserFour", "userFourPassword".toCharArray(), null);
+		assertNotNull(userFourModel);
+		assertNotNull(userFourModel.getTeam("git_users"));
+		assertNull(userFourModel.getTeam("git_admins"));
+		assertNull(userFourModel.getTeam("git admins"));
+		assertFalse(userFourModel.canAdmin);
+		assertFalse(userFourModel.canAdmin());
+		assertFalse(userFourModel.getTeam("git_users").canAdmin);
 	}
 
 	@Test
 	public void testBindWithUser() {
-		settings.put(Keys.realm.ldap.bindpattern, "CN=${username},OU=US,OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain");
+		// This test only makes sense if the user is not prevented from reading users and teams.
+		assumeTrue(authMode != AuthMode.DS_MANAGER);
+
+		settings.put(Keys.realm.ldap.bindpattern, "CN=${username},OU=US," + ACCOUNT_BASE);
 		settings.put(Keys.realm.ldap.username, "");
 		settings.put(Keys.realm.ldap.password, "");
 
@@ -276,6 +496,14 @@
 		assertNull(userOneModelFailedAuth);
 	}
 
+
+
+
+
+
+
+
+
 	private int countLdapUsersInUserManager() {
 		int ldapAccountCount = 0;
 		for (UserModel userModel : userManager.getAllUsers()) {
diff --git a/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java b/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java
new file mode 100644
index 0000000..7aec50e
--- /dev/null
+++ b/src/test/java/com/gitblit/tests/LdapBasedUnitTest.java
@@ -0,0 +1,410 @@
+package com.gitblit.tests;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.io.FileUtils;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+import com.gitblit.Keys;
+import com.gitblit.tests.mock.MemorySettings;
+import com.unboundid.ldap.listener.InMemoryDirectoryServer;
+import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
+import com.unboundid.ldap.listener.InMemoryDirectoryServerSnapshot;
+import com.unboundid.ldap.listener.InMemoryListenerConfig;
+import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedRequest;
+import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedResult;
+import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchEntry;
+import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchRequest;
+import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
+import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSimpleBindResult;
+import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
+import com.unboundid.ldap.sdk.BindRequest;
+import com.unboundid.ldap.sdk.BindResult;
+import com.unboundid.ldap.sdk.LDAPException;
+import com.unboundid.ldap.sdk.LDAPResult;
+import com.unboundid.ldap.sdk.OperationType;
+import com.unboundid.ldap.sdk.ResultCode;
+import com.unboundid.ldap.sdk.SimpleBindRequest;
+
+
+
+/**
+ * Base class for Unit (/Integration) tests that test going against an
+ * in-memory UnboundID LDAP server.
+ *
+ * This base class creates separate in-memory LDAP servers for different scenarios:
+ * - ANONYMOUS: anonymous bind to LDAP.
+ * - DS_MANAGER: The DIRECTORY_MANAGER is set as DN to bind as an admin.
+ *               Normal users are prohibited to search the DS, they can only bind.
+ * - USR_MANAGER: The USER_MANAGER is set as DN to bind as an admin.
+ *                This account can only search users but not groups. Normal users can search groups.
+ *
+ * @author Florian Zschocke
+ *
+ */
+public abstract class LdapBasedUnitTest extends GitblitUnitTest {
+
+	protected static final String RESOURCE_DIR = "src/test/resources/ldap/";
+	private static final String DIRECTORY_MANAGER = "cn=Directory Manager";
+	private static final String USER_MANAGER = "cn=UserManager";
+	protected static final String ACCOUNT_BASE = "OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain";
+	private static final String GROUP_BASE = "OU=Groups,OU=UserControl,OU=MyOrganization,DC=MyDomain";
+	protected static final String DN_USER_ONE = "CN=UserOne,OU=US," + ACCOUNT_BASE;
+	protected static final String DN_USER_TWO = "CN=UserTwo,OU=US," + ACCOUNT_BASE;
+	protected static final String DN_USER_THREE = "CN=UserThree,OU=Canada," + ACCOUNT_BASE;
+
+
+	/**
+	 * Enumeration of different test modes, representing different use scenarios.
+	 * With ANONYMOUS anonymous binds are used to search LDAP.
+	 * DS_MANAGER will use a DIRECTORY_MANAGER to search LDAP. Normal users are prohibited to search the DS.
+	 * With USR_MANAGER, a USER_MANAGER account is used to search in LDAP. This account can only search users
+	 * but not groups. Normal users can search groups, though.
+	 *
+	 */
+	protected enum AuthMode {
+		ANONYMOUS,
+		DS_MANAGER,
+		USR_MANAGER;
+
+
+		private int ldapPort;
+		private InMemoryDirectoryServer ds;
+		private InMemoryDirectoryServerSnapshot dsSnapshot;
+		private BindTracker bindTracker;
+
+		void setLdapPort(int port) {
+			this.ldapPort = port;
+		}
+
+		int ldapPort() {
+			return this.ldapPort;
+		}
+
+		void setDS(InMemoryDirectoryServer ds) {
+			if (this.ds == null) {
+				this.ds = ds;
+				this.dsSnapshot = ds.createSnapshot();
+			};
+		}
+
+		InMemoryDirectoryServer getDS() {
+			return ds;
+		}
+
+		void setBindTracker(BindTracker bindTracker) {
+			this.bindTracker = bindTracker;
+		}
+
+		BindTracker getBindTracker() {
+			return bindTracker;
+		}
+
+		void restoreSnapshot() {
+			ds.restoreSnapshot(dsSnapshot);
+		}
+	}
+
+	@Parameter
+	public AuthMode authMode = AuthMode.ANONYMOUS;
+
+	@Rule
+	public TemporaryFolder folder = new TemporaryFolder();
+
+
+	protected File usersConf;
+
+	protected MemorySettings settings;
+
+
+	/**
+	 * Run the tests with each authentication scenario once.
+	 */
+	@Parameters(name = "{0}")
+	public static Collection<Object[]> data() {
+		return Arrays.asList(new Object[][] { {AuthMode.ANONYMOUS}, {AuthMode.DS_MANAGER}, {AuthMode.USR_MANAGER} });
+	}
+
+
+	/**
+	 * Create three different in memory DS.
+	 *
+	 * Each DS has a different configuration:
+	 * The first allows anonymous binds.
+	 * The second requires authentication for all operations. It will only allow the DIRECTORY_MANAGER account
+	 * to search for users and groups.
+	 * The third one is like the second, but it allows users to search for users and groups, and restricts the
+	 * USER_MANAGER from searching for groups.
+	 */
+	@BeforeClass
+	public static void ldapInit() throws Exception {
+		InMemoryDirectoryServer ds;
+		InMemoryDirectoryServerConfig config = createInMemoryLdapServerConfig(AuthMode.ANONYMOUS);
+		config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("anonymous"));
+		ds = createInMemoryLdapServer(config);
+		AuthMode.ANONYMOUS.setDS(ds);
+		AuthMode.ANONYMOUS.setLdapPort(ds.getListenPort("anonymous"));
+
+
+		config = createInMemoryLdapServerConfig(AuthMode.DS_MANAGER);
+		config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("ds_manager"));
+		config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class));
+		ds = createInMemoryLdapServer(config);
+		AuthMode.DS_MANAGER.setDS(ds);
+		AuthMode.DS_MANAGER.setLdapPort(ds.getListenPort("ds_manager"));
+
+
+		config = createInMemoryLdapServerConfig(AuthMode.USR_MANAGER);
+		config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("usr_manager"));
+		config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class));
+		ds = createInMemoryLdapServer(config);
+		AuthMode.USR_MANAGER.setDS(ds);
+		AuthMode.USR_MANAGER.setLdapPort(ds.getListenPort("usr_manager"));
+
+	}
+
+	@AfterClass
+	public static void destroy() throws Exception {
+		for (AuthMode am : AuthMode.values()) {
+			am.getDS().shutDown(true);
+		}
+	}
+
+	public static InMemoryDirectoryServer createInMemoryLdapServer(InMemoryDirectoryServerConfig config) throws Exception {
+		InMemoryDirectoryServer imds = new InMemoryDirectoryServer(config);
+		imds.importFromLDIF(true, RESOURCE_DIR + "sampledata.ldif");
+		imds.startListening();
+		return imds;
+	}
+
+	public static InMemoryDirectoryServerConfig createInMemoryLdapServerConfig(AuthMode authMode) throws Exception {
+		InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=MyDomain");
+		config.addAdditionalBindCredentials(DIRECTORY_MANAGER, "password");
+		config.addAdditionalBindCredentials(USER_MANAGER, "passwd");
+		config.setSchema(null);
+
+		authMode.setBindTracker(new BindTracker());
+		config.addInMemoryOperationInterceptor(authMode.getBindTracker());
+		config.addInMemoryOperationInterceptor(new AccessInterceptor(authMode));
+
+		return config;
+	}
+
+
+
+	@Before
+	public void setupBase() throws Exception {
+		authMode.restoreSnapshot();
+		authMode.getBindTracker().reset();
+
+		usersConf = folder.newFile("users.conf");
+		FileUtils.copyFile(new File(RESOURCE_DIR + "users.conf"), usersConf);
+		settings = getSettings();
+	}
+
+
+	protected InMemoryDirectoryServer getDS() {
+		return authMode.getDS();
+	}
+
+
+
+	protected MemorySettings getSettings() {
+		Map<String, Object> backingMap = new HashMap<String, Object>();
+		backingMap.put(Keys.realm.userService, usersConf.getAbsolutePath());
+		backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort());
+		switch(authMode) {
+		case ANONYMOUS:
+			backingMap.put(Keys.realm.ldap.username, "");
+			backingMap.put(Keys.realm.ldap.password, "");
+			break;
+		case DS_MANAGER:
+			backingMap.put(Keys.realm.ldap.username, DIRECTORY_MANAGER);
+			backingMap.put(Keys.realm.ldap.password, "password");
+			break;
+		case USR_MANAGER:
+			backingMap.put(Keys.realm.ldap.username, USER_MANAGER);
+			backingMap.put(Keys.realm.ldap.password, "passwd");
+			break;
+		default:
+			throw new RuntimeException("Unimplemented AuthMode case!");
+
+		}
+		backingMap.put(Keys.realm.ldap.maintainTeams, "true");
+		backingMap.put(Keys.realm.ldap.accountBase, ACCOUNT_BASE);
+		backingMap.put(Keys.realm.ldap.accountPattern, "(&(objectClass=person)(sAMAccountName=${username}))");
+		backingMap.put(Keys.realm.ldap.groupBase, GROUP_BASE);
+		backingMap.put(Keys.realm.ldap.groupMemberPattern, "(&(objectClass=group)(member=${dn}))");
+		backingMap.put(Keys.realm.ldap.admins, "UserThree @Git_Admins \"@Git Admins\"");
+		backingMap.put(Keys.realm.ldap.displayName, "displayName");
+		backingMap.put(Keys.realm.ldap.email, "email");
+		backingMap.put(Keys.realm.ldap.uid, "sAMAccountName");
+
+		MemorySettings ms = new MemorySettings(backingMap);
+		return ms;
+	}
+
+
+
+
+	/**
+	 * Operation interceptor for the in memory DS. This interceptor
+	 * tracks bind requests.
+	 *
+	 */
+	protected static class BindTracker extends InMemoryOperationInterceptor {
+		private Map<Integer,String> lastSuccessfulBindDNs = new HashMap<>();
+		private String lastSuccessfulBindDN;
+
+
+		@Override
+		public void processSimpleBindResult(InMemoryInterceptedSimpleBindResult bind) {
+			BindResult result = bind.getResult();
+			if (result.getResultCode() == ResultCode.SUCCESS) {
+				 BindRequest bindRequest = bind.getRequest();
+				 lastSuccessfulBindDNs.put(bind.getMessageID(), ((SimpleBindRequest)bindRequest).getBindDN());
+				 lastSuccessfulBindDN = ((SimpleBindRequest)bindRequest).getBindDN();
+			}
+		}
+
+		String getLastSuccessfulBindDN() {
+			return lastSuccessfulBindDN;
+		}
+
+		String getLastSuccessfulBindDN(int messageID) {
+			return lastSuccessfulBindDNs.get(messageID);
+		}
+
+		void reset() {
+			lastSuccessfulBindDNs = new HashMap<>();
+			lastSuccessfulBindDN = null;
+		}
+	}
+
+
+
+	/**
+	 * Operation interceptor for the in memory DS. This interceptor
+	 * implements access restrictions for certain user/DN combinations.
+	 *
+	 * The USER_MANAGER is only allowed to search for users, but not for groups.
+	 * This is to test the original behaviour where the teams were searched under
+	 * the user binding.
+	 * When running in a DIRECTORY_MANAGER scenario, only the manager account
+	 * is allowed to search for users and groups, while a normal user may not do so.
+	 * This tests the scenario where a normal user cannot read teams and thus the
+	 * manager account needs to be used for all searches.
+	 *
+	 */
+	protected static class AccessInterceptor extends InMemoryOperationInterceptor {
+		AuthMode authMode;
+		Map<Long,String> lastSuccessfulBindDN = new HashMap<>();
+		Map<Long,Boolean> resultProhibited = new HashMap<>();
+
+		public AccessInterceptor(AuthMode authMode) {
+			this.authMode = authMode;
+		}
+
+
+		@Override
+		public void processSimpleBindResult(InMemoryInterceptedSimpleBindResult bind) {
+			BindResult result = bind.getResult();
+			if (result.getResultCode() == ResultCode.SUCCESS) {
+				 BindRequest bindRequest = bind.getRequest();
+				 lastSuccessfulBindDN.put(bind.getConnectionID(), ((SimpleBindRequest)bindRequest).getBindDN());
+				 resultProhibited.remove(bind.getConnectionID());
+			}
+		}
+
+
+
+		@Override
+		public void processSearchRequest(InMemoryInterceptedSearchRequest request) throws LDAPException {
+			String bindDN = getLastBindDN(request);
+
+			if (USER_MANAGER.equals(bindDN)) {
+				if (request.getRequest().getBaseDN().endsWith(GROUP_BASE)) {
+					throw new LDAPException(ResultCode.NO_SUCH_OBJECT);
+				}
+			}
+			else if(authMode == AuthMode.DS_MANAGER && !DIRECTORY_MANAGER.equals(bindDN)) {
+				throw new LDAPException(ResultCode.NO_SUCH_OBJECT);
+			}
+		}
+
+
+		@Override
+		public void processSearchEntry(InMemoryInterceptedSearchEntry entry) {
+			String bindDN = getLastBindDN(entry);
+
+			boolean prohibited = false;
+
+			if (USER_MANAGER.equals(bindDN)) {
+				if (entry.getSearchEntry().getDN().endsWith(GROUP_BASE)) {
+					prohibited = true;
+				}
+			}
+			else if(authMode == AuthMode.DS_MANAGER && !DIRECTORY_MANAGER.equals(bindDN)) {
+				prohibited = true;
+			}
+
+			if (prohibited) {
+				// Found entry prohibited for bound user. Setting entry to null.
+				entry.setSearchEntry(null);
+				resultProhibited.put(entry.getConnectionID(), Boolean.TRUE);
+			}
+		}
+
+		@Override
+		public void processSearchResult(InMemoryInterceptedSearchResult result) {
+			String bindDN = getLastBindDN(result);
+
+			boolean prohibited = false;
+
+			Boolean rspb = resultProhibited.get(result.getConnectionID());
+			if (USER_MANAGER.equals(bindDN)) {
+				if (rspb != null && rspb) {
+					prohibited = true;
+				}
+			}
+			else if(authMode == AuthMode.DS_MANAGER && !DIRECTORY_MANAGER.equals(bindDN)) {
+				if (rspb != null && rspb) {
+					prohibited = true;
+				}
+			}
+
+			if (prohibited) {
+				// Result prohibited for bound user. Returning error
+				result.setResult(new LDAPResult(result.getMessageID(), ResultCode.INSUFFICIENT_ACCESS_RIGHTS));
+				resultProhibited.remove(result.getConnectionID());
+			}
+		}
+
+		private String getLastBindDN(InMemoryInterceptedResult result) {
+			String bindDN = lastSuccessfulBindDN.get(result.getConnectionID());
+			if (bindDN == null) {
+				return "UNKNOWN";
+			}
+			return bindDN;
+		}
+		private String getLastBindDN(InMemoryInterceptedRequest request) {
+			String bindDN = lastSuccessfulBindDN.get(request.getConnectionID());
+			if (bindDN == null) {
+				return "UNKNOWN";
+			}
+			return bindDN;
+		}
+	}
+
+}
diff --git a/src/test/java/com/gitblit/tests/LdapConnectionTest.java b/src/test/java/com/gitblit/tests/LdapConnectionTest.java
new file mode 100644
index 0000000..3da5477
--- /dev/null
+++ b/src/test/java/com/gitblit/tests/LdapConnectionTest.java
@@ -0,0 +1,280 @@
+package com.gitblit.tests;
+
+import static org.junit.Assume.assumeTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import com.gitblit.Keys;
+import com.gitblit.ldap.LdapConnection;
+import com.unboundid.ldap.sdk.BindResult;
+import com.unboundid.ldap.sdk.LDAPException;
+import com.unboundid.ldap.sdk.ResultCode;
+import com.unboundid.ldap.sdk.SearchRequest;
+import com.unboundid.ldap.sdk.SearchResult;
+import com.unboundid.ldap.sdk.SearchResultEntry;
+import com.unboundid.ldap.sdk.SearchScope;
+
+/*
+ * Test for the LdapConnection
+ *
+ * @author Florian Zschocke
+ *
+ */
+@RunWith(Parameterized.class)
+public class LdapConnectionTest extends LdapBasedUnitTest {
+
+	@Test
+	public void testEscapeLDAPFilterString() {
+		// This test is independent from authentication mode, so run only once.
+		assumeTrue(authMode == AuthMode.ANONYMOUS);
+
+		// From: https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java
+		assertEquals("No special characters to escape", "Hi This is a test #çà", LdapConnection.escapeLDAPSearchFilter("Hi This is a test #çà"));
+		assertEquals("LDAP Christams Tree", "Hi \\28This\\29 = is \\2a a \\5c test # ç à ô", LdapConnection.escapeLDAPSearchFilter("Hi (This) = is * a \\ test # ç à ô"));
+
+		assertEquals("Injection", "\\2a\\29\\28userPassword=secret", LdapConnection.escapeLDAPSearchFilter("*)(userPassword=secret"));
+	}
+
+
+	@Test
+	public void testConnect() {
+		// This test is independent from authentication mode, so run only once.
+		assumeTrue(authMode == AuthMode.ANONYMOUS);
+
+		LdapConnection conn = new LdapConnection(settings);
+		try {
+			assertTrue(conn.connect());
+		} finally {
+			conn.close();
+		}
+	}
+
+
+	@Test
+	public void testBindAnonymous() {
+		// This test tests for anonymous bind, so run only in authentication mode ANONYMOUS.
+		assumeTrue(authMode == AuthMode.ANONYMOUS);
+
+		LdapConnection conn = new LdapConnection(settings);
+		try {
+			assertTrue(conn.connect());
+
+			BindResult br = conn.bind();
+			assertNotNull(br);
+			assertEquals(ResultCode.SUCCESS, br.getResultCode());
+			assertEquals("", authMode.getBindTracker().getLastSuccessfulBindDN(br.getMessageID()));
+
+		} finally {
+			conn.close();
+		}
+	}
+
+
+	@Test
+	public void testBindAsAdmin() {
+		// This test tests for anonymous bind, so run only in authentication mode DS_MANAGER.
+		assumeTrue(authMode == AuthMode.DS_MANAGER);
+
+		LdapConnection conn = new LdapConnection(settings);
+		try {
+			assertTrue(conn.connect());
+
+			BindResult br = conn.bind();
+			assertNotNull(br);
+			assertEquals(ResultCode.SUCCESS, br.getResultCode());
+			assertEquals(settings.getString(Keys.realm.ldap.username, "UNSET"), authMode.getBindTracker().getLastSuccessfulBindDN(br.getMessageID()));
+
+		} finally {
+			conn.close();
+		}
+	}
+
+
+	@Test
+	public void testBindToBindpattern() {
+		LdapConnection conn = new LdapConnection(settings);
+		try {
+			assertTrue(conn.connect());
+
+			String bindPattern = "CN=${username},OU=Canada," + ACCOUNT_BASE;
+
+			BindResult br = conn.bind(bindPattern, "UserThree", "userThreePassword");
+			assertNotNull(br);
+			assertEquals(ResultCode.SUCCESS, br.getResultCode());
+			assertEquals("CN=UserThree,OU=Canada," + ACCOUNT_BASE, authMode.getBindTracker().getLastSuccessfulBindDN(br.getMessageID()));
+
+			br = conn.bind(bindPattern, "UserFour", "userThreePassword");
+			assertNull(br);
+
+			br = conn.bind(bindPattern, "UserTwo", "userTwoPassword");
+			assertNull(br);
+
+		} finally {
+			conn.close();
+		}
+	}
+
+
+	@Test
+	public void testRebindAsUser() {
+		LdapConnection conn = new LdapConnection(settings);
+		try {
+			assertTrue(conn.connect());
+
+			assertFalse(conn.rebindAsUser());
+
+			BindResult br = conn.bind();
+			assertNotNull(br);
+			assertFalse(conn.rebindAsUser());
+
+
+			String bindPattern = "CN=${username},OU=Canada," + ACCOUNT_BASE;
+			br = conn.bind(bindPattern, "UserThree", "userThreePassword");
+			assertNotNull(br);
+			assertFalse(conn.rebindAsUser());
+
+			br = conn.bind();
+			assertNotNull(br);
+			assertTrue(conn.rebindAsUser());
+			assertEquals(ResultCode.SUCCESS, br.getResultCode());
+			assertEquals("CN=UserThree,OU=Canada," + ACCOUNT_BASE, authMode.getBindTracker().getLastSuccessfulBindDN());
+
+		} finally {
+			conn.close();
+		}
+	}
+
+
+
+	@Test
+	public void testSearchRequest() throws LDAPException {
+		LdapConnection conn = new LdapConnection(settings);
+		try {
+			assertTrue(conn.connect());
+			BindResult br = conn.bind();
+			assertNotNull(br);
+
+			SearchRequest req;
+			SearchResult result;
+			SearchResultEntry entry;
+
+			req = new SearchRequest(ACCOUNT_BASE, SearchScope.BASE, "(CN=UserOne)");
+			result = conn.search(req);
+			assertNotNull(result);
+			assertEquals(0, result.getEntryCount());
+
+			req = new SearchRequest(ACCOUNT_BASE, SearchScope.ONE, "(CN=UserTwo)");
+			result = conn.search(req);
+			assertNotNull(result);
+			assertEquals(0, result.getEntryCount());
+
+			req = new SearchRequest(ACCOUNT_BASE, SearchScope.SUB, "(CN=UserThree)");
+			result = conn.search(req);
+			assertNotNull(result);
+			assertEquals(1, result.getEntryCount());
+			entry = result.getSearchEntries().get(0);
+			assertEquals("CN=UserThree,OU=Canada," + ACCOUNT_BASE, entry.getDN());
+
+			req = new SearchRequest(ACCOUNT_BASE, SearchScope.SUBORDINATE_SUBTREE, "(CN=UserFour)");
+			result = conn.search(req);
+			assertNotNull(result);
+			assertEquals(1, result.getEntryCount());
+			entry = result.getSearchEntries().get(0);
+			assertEquals("CN=UserFour,OU=Canada," + ACCOUNT_BASE, entry.getDN());
+
+		} finally {
+			conn.close();
+		}
+	}
+
+
+	@Test
+	public void testSearch() throws LDAPException {
+		LdapConnection conn = new LdapConnection(settings);
+		try {
+			assertTrue(conn.connect());
+			BindResult br = conn.bind();
+			assertNotNull(br);
+
+			SearchResult result;
+			SearchResultEntry entry;
+
+			result = conn.search(ACCOUNT_BASE, false, "(CN=UserOne)", null);
+			assertNotNull(result);
+			assertEquals(1, result.getEntryCount());
+			entry = result.getSearchEntries().get(0);
+			assertEquals("CN=UserOne,OU=US," + ACCOUNT_BASE, entry.getDN());
+
+			result = conn.search(ACCOUNT_BASE, true, "(&(CN=UserOne)(surname=One))", null);
+			assertNotNull(result);
+			assertEquals(1, result.getEntryCount());
+			entry = result.getSearchEntries().get(0);
+			assertEquals("CN=UserOne,OU=US," + ACCOUNT_BASE, entry.getDN());
+
+			result = conn.search(ACCOUNT_BASE, true, "(&(CN=UserOne)(surname=Two))", null);
+			assertNotNull(result);
+			assertEquals(0, result.getEntryCount());
+
+			result = conn.search(ACCOUNT_BASE, true, "(surname=Two)", Arrays.asList("givenName", "surname"));
+			assertNotNull(result);
+			assertEquals(1, result.getEntryCount());
+			entry = result.getSearchEntries().get(0);
+			assertEquals("CN=UserTwo,OU=US," + ACCOUNT_BASE, entry.getDN());
+			assertEquals(2, entry.getAttributes().size());
+			assertEquals("User", entry.getAttributeValue("givenName"));
+			assertEquals("Two", entry.getAttributeValue("surname"));
+
+			result = conn.search(ACCOUNT_BASE, true, "(personalTitle=Mr*)", null);
+			assertNotNull(result);
+			assertEquals(3, result.getEntryCount());
+			ArrayList<String> names = new ArrayList<>(3);
+			names.add(result.getSearchEntries().get(0).getAttributeValue("surname"));
+			names.add(result.getSearchEntries().get(1).getAttributeValue("surname"));
+			names.add(result.getSearchEntries().get(2).getAttributeValue("surname"));
+			assertTrue(names.contains("One"));
+			assertTrue(names.contains("Two"));
+			assertTrue(names.contains("Three"));
+
+		} finally {
+			conn.close();
+		}
+	}
+
+
+	@Test
+	public void testSearchUser() throws LDAPException {
+		LdapConnection conn = new LdapConnection(settings);
+		try {
+			assertTrue(conn.connect());
+			BindResult br = conn.bind();
+			assertNotNull(br);
+
+			SearchResult result;
+			SearchResultEntry entry;
+
+			result = conn.searchUser("UserOne");
+			assertNotNull(result);
+			assertEquals(1, result.getEntryCount());
+			entry = result.getSearchEntries().get(0);
+			assertEquals("CN=UserOne,OU=US," + ACCOUNT_BASE, entry.getDN());
+
+			result = conn.searchUser("UserFour", Arrays.asList("givenName", "surname"));
+			assertNotNull(result);
+			assertEquals(1, result.getEntryCount());
+			entry = result.getSearchEntries().get(0);
+			assertEquals("CN=UserFour,OU=Canada," + ACCOUNT_BASE, entry.getDN());
+			assertEquals(2, entry.getAttributes().size());
+			assertEquals("User", entry.getAttributeValue("givenName"));
+			assertEquals("Four", entry.getAttributeValue("surname"));
+
+		} finally {
+			conn.close();
+		}
+	}
+
+}
diff --git a/src/test/java/com/gitblit/tests/LdapPublicKeyManagerTest.java b/src/test/java/com/gitblit/tests/LdapPublicKeyManagerTest.java
new file mode 100644
index 0000000..c426254
--- /dev/null
+++ b/src/test/java/com/gitblit/tests/LdapPublicKeyManagerTest.java
@@ -0,0 +1,723 @@
+/*
+ * Copyright 2016 Florian Zschocke
+ * Copyright 2016 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.tests;
+
+import static org.junit.Assume.assumeTrue;
+
+import java.security.GeneralSecurityException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.Signature;
+import java.security.spec.ECGenParameterSpec;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sshd.common.util.SecurityUtils;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import com.gitblit.Keys;
+import com.gitblit.Constants.AccessPermission;
+import com.gitblit.transport.ssh.LdapKeyManager;
+import com.gitblit.transport.ssh.SshKey;
+import com.unboundid.ldap.sdk.LDAPException;
+import com.unboundid.ldap.sdk.Modification;
+import com.unboundid.ldap.sdk.ModificationType;
+
+/**
+ * Test LdapPublicKeyManager going against an in-memory UnboundID
+ * LDAP server.
+ *
+ * @author Florian Zschocke
+ *
+ */
+@RunWith(Parameterized.class)
+public class LdapPublicKeyManagerTest extends LdapBasedUnitTest {
+
+	private static Map<String,KeyPair> keyPairs = new HashMap<>(10);
+	private static KeyPairGenerator rsaGenerator;
+	private static KeyPairGenerator dsaGenerator;
+	private static KeyPairGenerator ecGenerator;
+
+
+
+	@BeforeClass
+	public static void init() throws GeneralSecurityException {
+		rsaGenerator = SecurityUtils.getKeyPairGenerator("RSA");
+		dsaGenerator = SecurityUtils.getKeyPairGenerator("DSA");
+		ecGenerator = SecurityUtils.getKeyPairGenerator("ECDSA");
+	}
+
+
+
+	@Test
+	public void testGetKeys() throws LDAPException {
+		String keyRsaOne = getRsaPubKey("UserOne@example.com");
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaOne));
+
+		String keyRsaTwo = getRsaPubKey("UserTwo@example.com");
+		String keyDsaTwo = getDsaPubKey("UserTwo@example.com");
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaTwo, keyDsaTwo));
+
+		String keyRsaThree = getRsaPubKey("UserThree@example.com");
+		String keyDsaThree = getDsaPubKey("UserThree@example.com");
+		String keyEcThree  = getEcPubKey("UserThree@example.com");
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", keyEcThree, keyRsaThree, keyDsaThree));
+
+		LdapKeyManager kmgr = new LdapKeyManager(settings);
+
+		List<SshKey> keys = kmgr.getKeys("UserOne");
+		assertNotNull(keys);
+		assertTrue(keys.size() == 1);
+		assertEquals(keyRsaOne, keys.get(0).getRawData());
+
+
+		keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertTrue(keys.size() == 2);
+		if (keyRsaTwo.equals(keys.get(0).getRawData())) {
+			assertEquals(keyDsaTwo, keys.get(1).getRawData());
+		} else if (keyDsaTwo.equals(keys.get(0).getRawData())) {
+			assertEquals(keyRsaTwo, keys.get(1).getRawData());
+		} else {
+			fail("Mismatch in UserTwo keys.");
+		}
+
+
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertTrue(keys.size() == 3);
+		assertEquals(keyEcThree, keys.get(0).getRawData());
+		assertEquals(keyRsaThree, keys.get(1).getRawData());
+		assertEquals(keyDsaThree, keys.get(2).getRawData());
+
+		keys = kmgr.getKeys("UserFour");
+		assertNotNull(keys);
+		assertTrue(keys.size() == 0);
+	}
+
+
+	@Test
+	public void testGetKeysAttributeName() throws LDAPException {
+		settings.put(Keys.realm.ldap.sshPublicKey, "sshPublicKey");
+
+		String keyRsaOne = getRsaPubKey("UserOne@example.com");
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaOne));
+
+		String keyDsaTwo = getDsaPubKey("UserTwo@example.com");
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "publicsshkey", keyDsaTwo));
+
+		String keyRsaThree = getRsaPubKey("UserThree@example.com");
+		String keyDsaThree = getDsaPubKey("UserThree@example.com");
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaThree));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "publicsshkey", keyDsaThree));
+
+
+		LdapKeyManager kmgr = new LdapKeyManager(settings);
+
+		List<SshKey> keys = kmgr.getKeys("UserOne");
+		assertNotNull(keys);
+		assertEquals(1, keys.size());
+		assertEquals(keyRsaOne, keys.get(0).getRawData());
+
+		keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertEquals(1, keys.size());
+		assertEquals(keyRsaThree, keys.get(0).getRawData());
+
+		keys = kmgr.getKeys("UserFour");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+
+		settings.put(Keys.realm.ldap.sshPublicKey, "publicsshkey");
+
+		keys = kmgr.getKeys("UserOne");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+		keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertEquals(1, keys.size());
+		assertEquals(keyDsaTwo, keys.get(0).getRawData());
+
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertEquals(1, keys.size());
+		assertEquals(keyDsaThree, keys.get(0).getRawData());
+
+		keys = kmgr.getKeys("UserFour");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+	}
+
+
+	@Test
+	public void testGetKeysPrefixed() throws LDAPException {
+		// This test is independent from authentication mode, so run only once.
+		assumeTrue(authMode == AuthMode.ANONYMOUS);
+
+		String keyRsaOne = getRsaPubKey("UserOne@example.com");
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaOne));
+
+		String keyRsaTwo = getRsaPubKey("UserTwo@example.com");
+		String keyDsaTwo = getDsaPubKey("UserTwo@example.com");
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", keyRsaTwo));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHKey: " + keyDsaTwo));
+
+		String keyRsaThree = getRsaPubKey("UserThree@example.com");
+		String keyDsaThree = getDsaPubKey("UserThree@example.com");
+		String keyEcThree =  getEcPubKey("UserThree@example.com");
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", " SshKey :\r\n" + keyRsaThree));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "	sshkey: " + keyDsaThree));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "ECDSAKey	:\n " + keyEcThree));
+
+
+		LdapKeyManager kmgr = new LdapKeyManager(settings);
+
+		settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities");
+
+		List<SshKey> keys = kmgr.getKeys("UserOne");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+		keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertEquals(1, keys.size());
+		assertEquals(keyRsaTwo, keys.get(0).getRawData());
+
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+		keys = kmgr.getKeys("UserFour");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+
+
+		settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:SSHKey");
+
+		keys = kmgr.getKeys("UserOne");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+		keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertEquals(1, keys.size());
+		assertEquals(keyDsaTwo, keys.get(0).getRawData());
+
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertEquals(2, keys.size());
+		assertEquals(keyRsaThree, keys.get(0).getRawData());
+		assertEquals(keyDsaThree, keys.get(1).getRawData());
+
+		keys = kmgr.getKeys("UserFour");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+
+
+		settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:ECDSAKey");
+
+		keys = kmgr.getKeys("UserOne");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+		keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertEquals(1, keys.size());
+		assertEquals(keyEcThree, keys.get(0).getRawData());
+
+		keys = kmgr.getKeys("UserFour");
+		assertNotNull(keys);
+		assertEquals(0, keys.size());
+	}
+
+
+	@Test
+	public void testGetKeysPermissions() throws LDAPException {
+		// This test is independent from authentication mode, so run only once.
+		assumeTrue(authMode == AuthMode.ANONYMOUS);
+
+		String keyRsaOne = getRsaPubKey("UserOne@example.com");
+		String keyRsaTwo = getRsaPubKey("");
+		String keyDsaTwo = getDsaPubKey("UserTwo at example.com");
+		String keyRsaThree = getRsaPubKey("UserThree@example.com");
+		String keyDsaThree = getDsaPubKey("READ key for user 'Three' @example.com");
+		String keyEcThree =  getEcPubKey("UserThree@example.com");
+
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", keyRsaOne));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", "  	 " + keyRsaTwo));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", "no-agent-forwarding " + keyDsaTwo));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", " command=\"sh /etc/netstart tun0 \" " + keyRsaThree));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", " command=\"netstat -nult\",environment=\"gb=\\\"What now\\\"\" " + keyDsaThree));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"SSH=git\",command=\"netstat -nult\",environment=\"gbPerms=VIEW\" " + keyEcThree));
+
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"gbPerm=R\" " + keyRsaOne));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", " restrict,environment=\"gbperm=V\" 	 " + keyRsaTwo));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", "restrict,environment=\"GBPerm=RW\",pty " + keyDsaTwo));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", " environment=\"gbPerm=CLONE\",environment=\"X=\\\" Y \\\"\" " + keyRsaThree));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", " environment=\"A = B \",from=\"*.example.com,!pc.example.com\",environment=\"gbPerm=VIEW\" " + keyDsaThree));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"SSH=git\",environment=\"gbPerm=PUSH\",environment=\"XYZ='Ali Baba'\" " + keyEcThree));
+
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"gbPerm=R\",environment=\"josh=\\\"mean\\\"\",tunnel=\"0\" " + keyRsaOne));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", " environment=\" gbPerm = V \" 	 " + keyRsaTwo));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", "command=\"sh echo \\\"Nope, not you!\\\" \",user-rc,environment=\"gbPerm=RW\" " + keyDsaTwo));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"gbPerm=VIEW\",command=\"sh /etc/netstart tun0 \",environment=\"gbPerm=CLONE\",no-pty " + keyRsaThree));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", "	command=\"netstat -nult\",environment=\"gbPerm=VIEW\" " + keyDsaThree));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "sshPublicKey", "environment=\"SSH=git\",command=\"netstat -nult\",environment=\"gbPerm=PUSH\" " + keyEcThree));
+
+
+		LdapKeyManager kmgr = new LdapKeyManager(settings);
+
+		List<SshKey> keys = kmgr.getKeys("UserOne");
+		assertNotNull(keys);
+		assertEquals(6, keys.size());
+		for (SshKey key : keys) {
+			assertEquals(AccessPermission.PUSH, key.getPermission());
+		}
+
+		keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertEquals(6, keys.size());
+		int seen = 0;
+		for (SshKey key : keys) {
+			if (keyRsaOne.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 0;
+			}
+			else if (keyRsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 1;
+			}
+			else if (keyDsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 2;
+			}
+			else if (keyRsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 3;
+			}
+			else if (keyDsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 4;
+			}
+			else if (keyEcThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 5;
+			}
+		}
+		assertEquals(63, seen);
+
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertEquals(6, keys.size());
+		seen = 0;
+		for (SshKey key : keys) {
+			if (keyRsaOne.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 0;
+			}
+			else if (keyRsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 1;
+			}
+			else if (keyDsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 2;
+			}
+			else if (keyRsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 3;
+			}
+			else if (keyDsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 4;
+			}
+			else if (keyEcThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 5;
+			}
+		}
+		assertEquals(63, seen);
+	}
+
+
+	@Test
+	public void testGetKeysPrefixedPermissions() throws LDAPException {
+		// This test is independent from authentication mode, so run only once.
+		assumeTrue(authMode == AuthMode.ANONYMOUS);
+
+		String keyRsaOne = getRsaPubKey("UserOne@example.com");
+		String keyRsaTwo = getRsaPubKey("UserTwo at example.com");
+		String keyDsaTwo = getDsaPubKey("UserTwo@example.com");
+		String keyRsaThree = getRsaPubKey("example.com: user Three");
+		String keyDsaThree = getDsaPubKey("");
+		String keyEcThree =  getEcPubKey("  ");
+
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities",          "permitopen=\"host:220\"" + keyRsaOne));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "sshkey:" + "  	 " + keyRsaTwo));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHKEY :" + "no-agent-forwarding " + keyDsaTwo));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + " command=\"sh /etc/netstart tun0 \" " + keyRsaThree));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + " command=\"netstat -nult\",environment=\"gb=\\\"What now\\\"\" " + keyDsaThree));
+		getDS().modify(DN_USER_ONE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"SSH=git\",command=\"netstat -nult\",environment=\"gbPerms=VIEW\" " + keyEcThree));
+
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey: " + "environment=\"gbPerm=R\" " + keyRsaOne));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHKey : " + " restrict,environment=\"gbPerm=V\",permitopen=\"sshkey: 220\" " + keyRsaTwo));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey: " + "permitopen=\"sshkey: 443\",restrict,environment=\"gbPerm=RW\",pty " + keyDsaTwo));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"gbPerm=CLONE\",permitopen=\"pubkey: 29184\",environment=\"X=\\\" Y \\\"\" " + keyRsaThree));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + " environment=\"A = B \",from=\"*.example.com,!pc.example.com\",environment=\"gbPerm=VIEW\" " + keyDsaThree));
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"SSH=git\",environment=\"gbPerm=PUSH\",environemnt=\"XYZ='Ali Baba'\" " + keyEcThree));
+
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey: " + "environment=\"gbPerm=R\",environment=\"josh=\\\"mean\\\"\",tunnel=\"0\" " + keyRsaOne));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey : " + " environment=\" gbPerm = V \" 	 " + keyRsaTwo));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "SSHkey: " + "command=\"sh echo \\\"Nope, not you! \\b (bell)\\\" \",user-rc,environment=\"gbPerm=RW\" " + keyDsaTwo));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"gbPerm=VIEW\",command=\"sh /etc/netstart tun0 \",environment=\"gbPerm=CLONE\",no-pty " + keyRsaThree));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "	command=\"netstat -nult\",environment=\"gbPerm=VIEW\" " + keyDsaThree));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "pubkey: " + "environment=\"SSH=git\",command=\"netstat -nult\",environment=\"gbPerm=PUSH\" " + keyEcThree));
+
+		// Weird stuff, not to specification but shouldn't make it stumble.
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "opttest: " + "permitopen=host:443,command=,environment=\"gbPerm=CLONE\",no-pty= " + keyRsaThree));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", " opttest: " + "	cmd=git,environment=\"gbPerm=\\\"VIEW\\\"\" " + keyDsaThree));
+		getDS().modify(DN_USER_THREE, new Modification(ModificationType.ADD, "altSecurityIdentities", "	opttest:" + "environment=,command=netstat,environment=gbperm=push " + keyEcThree));
+
+
+		LdapKeyManager kmgr = new LdapKeyManager(settings);
+
+		settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:SSHkey");
+
+		List<SshKey> keys = kmgr.getKeys("UserOne");
+		assertNotNull(keys);
+		assertEquals(2, keys.size());
+		int seen = 0;
+		for (SshKey key : keys) {
+			assertEquals(AccessPermission.PUSH, key.getPermission());
+			if (keyRsaOne.equals(key.getRawData())) {
+				seen += 1 << 0;
+			}
+			else if (keyRsaTwo.equals(key.getRawData())) {
+				seen += 1 << 1;
+			}
+			else if (keyDsaTwo.equals(key.getRawData())) {
+				seen += 1 << 2;
+			}
+			else if (keyRsaThree.equals(key.getRawData())) {
+				seen += 1 << 3;
+			}
+			else if (keyDsaThree.equals(key.getRawData())) {
+				seen += 1 << 4;
+			}
+			else if (keyEcThree.equals(key.getRawData())) {
+				seen += 1 << 5;
+			}
+		}
+		assertEquals(6, seen);
+
+		keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertEquals(3, keys.size());
+		seen = 0;
+		for (SshKey key : keys) {
+			if (keyRsaOne.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 0;
+			}
+			else if (keyRsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 1;
+			}
+			else if (keyDsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 2;
+			}
+			else if (keyRsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 3;
+			}
+			else if (keyDsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 4;
+			}
+			else if (keyEcThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 5;
+			}
+		}
+		assertEquals(7, seen);
+
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertEquals(3, keys.size());
+		seen = 0;
+		for (SshKey key : keys) {
+			if (keyRsaOne.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 0;
+			}
+			else if (keyRsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 1;
+			}
+			else if (keyDsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 2;
+			}
+			else if (keyRsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 3;
+			}
+			else if (keyDsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 4;
+			}
+			else if (keyEcThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 5;
+			}
+		}
+		assertEquals(7, seen);
+
+
+
+		settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:pubKey");
+
+		keys = kmgr.getKeys("UserOne");
+		assertNotNull(keys);
+		assertEquals(3, keys.size());
+		seen = 0;
+		for (SshKey key : keys) {
+			assertEquals(AccessPermission.PUSH, key.getPermission());
+			if (keyRsaOne.equals(key.getRawData())) {
+				seen += 1 << 0;
+			}
+			else if (keyRsaTwo.equals(key.getRawData())) {
+				seen += 1 << 1;
+			}
+			else if (keyDsaTwo.equals(key.getRawData())) {
+				seen += 1 << 2;
+			}
+			else if (keyRsaThree.equals(key.getRawData())) {
+				seen += 1 << 3;
+			}
+			else if (keyDsaThree.equals(key.getRawData())) {
+				seen += 1 << 4;
+			}
+			else if (keyEcThree.equals(key.getRawData())) {
+				seen += 1 << 5;
+			}
+		}
+		assertEquals(56, seen);
+
+		keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertEquals(3, keys.size());
+		seen = 0;
+		for (SshKey key : keys) {
+			if (keyRsaOne.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 0;
+			}
+			else if (keyRsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 1;
+			}
+			else if (keyDsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 2;
+			}
+			else if (keyRsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 3;
+			}
+			else if (keyDsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 4;
+			}
+			else if (keyEcThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 5;
+			}
+		}
+		assertEquals(56, seen);
+
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertEquals(3, keys.size());
+		seen = 0;
+		for (SshKey key : keys) {
+			if (keyRsaOne.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 0;
+			}
+			else if (keyRsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 1;
+			}
+			else if (keyDsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 2;
+			}
+			else if (keyRsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 3;
+			}
+			else if (keyDsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 4;
+			}
+			else if (keyEcThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 5;
+			}
+		}
+		assertEquals(56, seen);
+
+
+		settings.put(Keys.realm.ldap.sshPublicKey, "altSecurityIdentities:opttest");
+		keys = kmgr.getKeys("UserThree");
+		assertNotNull(keys);
+		assertEquals(3, keys.size());
+		seen = 0;
+		for (SshKey key : keys) {
+			if (keyRsaOne.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 0;
+			}
+			else if (keyRsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 1;
+			}
+			else if (keyDsaTwo.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 2;
+			}
+			else if (keyRsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.CLONE, key.getPermission());
+				seen += 1 << 3;
+			}
+			else if (keyDsaThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.VIEW, key.getPermission());
+				seen += 1 << 4;
+			}
+			else if (keyEcThree.equals(key.getRawData())) {
+				assertEquals(AccessPermission.PUSH, key.getPermission());
+				seen += 1 << 5;
+			}
+		}
+		assertEquals(56, seen);
+
+	}
+
+
+	@Test
+	public void testKeyValidity() throws LDAPException, GeneralSecurityException {
+		LdapKeyManager kmgr = new LdapKeyManager(settings);
+
+		String comment = "UserTwo@example.com";
+		String keyDsaTwo = getDsaPubKey(comment);
+		getDS().modify(DN_USER_TWO, new Modification(ModificationType.ADD, "sshPublicKey", keyDsaTwo));
+
+
+		List<SshKey> keys = kmgr.getKeys("UserTwo");
+		assertNotNull(keys);
+		assertEquals(1, keys.size());
+		SshKey sshKey = keys.get(0);
+		assertEquals(keyDsaTwo, sshKey.getRawData());
+
+		Signature signature = SecurityUtils.getSignature("DSA");
+		signature.initSign(getDsaKeyPair(comment).getPrivate());
+		byte[] message = comment.getBytes();
+		signature.update(message);
+		byte[] sigBytes = signature.sign();
+
+		signature.initVerify(sshKey.getPublicKey());
+		signature.update(message);
+		assertTrue("Verify failed with retrieved SSH key.", signature.verify(sigBytes));
+	}
+
+
+
+
+
+
+
+
+	private KeyPair getDsaKeyPair(String comment) {
+		return getKeyPair("DSA", comment, dsaGenerator);
+	}
+
+	private KeyPair getKeyPair(String type, String comment, KeyPairGenerator generator) {
+		String kpkey = type + ":" + comment;
+		KeyPair kp = keyPairs.get(kpkey);
+		if (kp == null) {
+			if ("EC".equals(type)) {
+				ECGenParameterSpec ecSpec = new ECGenParameterSpec("P-384");
+				try {
+					ecGenerator.initialize(ecSpec);
+				} catch (InvalidAlgorithmParameterException e) {
+					kp = generator.generateKeyPair();
+					e.printStackTrace();
+				}
+				kp = ecGenerator.generateKeyPair();
+			} else {
+				kp = generator.generateKeyPair();
+			}
+			keyPairs.put(kpkey, kp);
+		}
+
+		return kp;
+	}
+
+
+	private String getRsaPubKey(String comment) {
+		return getPubKey("RSA", comment, rsaGenerator);
+	}
+
+	private String getDsaPubKey(String comment) {
+		return getPubKey("DSA", comment, dsaGenerator);
+	}
+
+	private String getEcPubKey(String comment) {
+		return getPubKey("EC", comment, ecGenerator);
+	}
+
+	private String getPubKey(String type, String comment, KeyPairGenerator generator) {
+		KeyPair kp = getKeyPair(type, comment, generator);
+		if (kp == null) {
+			return null;
+		}
+
+		SshKey sk = new SshKey(kp.getPublic());
+		sk.setComment(comment);
+		return sk.getRawData();
+	}
+
+}
diff --git a/src/test/java/com/gitblit/tests/MarkdownUtilsTest.java b/src/test/java/com/gitblit/tests/MarkdownUtilsTest.java
index e40f105..bc7aad4 100644
--- a/src/test/java/com/gitblit/tests/MarkdownUtilsTest.java
+++ b/src/test/java/com/gitblit/tests/MarkdownUtilsTest.java
@@ -15,8 +15,14 @@
  */

 package com.gitblit.tests;

 

+import java.util.HashMap;

+import java.util.Map;

+

 import org.junit.Test;

 

+import com.gitblit.IStoredSettings;

+import com.gitblit.Keys;

+import com.gitblit.tests.mock.MemorySettings;

 import com.gitblit.utils.MarkdownUtils;

 

 public class MarkdownUtilsTest extends GitblitUnitTest {

@@ -39,4 +45,70 @@
 		assertEquals("<table><tr><td>&lt;test&gt;</td></tr></table>",

 				MarkdownUtils.transformMarkdown("<table><tr><td>&lt;test&gt;</td></tr></table>"));

 	}

-}
\ No newline at end of file
+

+

+	@Test

+	public void testUserMentions() {

+		IStoredSettings settings = getSettings();

+		String repositoryName = "test3";

+		String mentionHtml = "<strong><a href=\"http://localhost/user/%1$s\">@%1$s</a></strong>";

+

+		String input = "@j.doe";

+		String output = "<p>" + String.format(mentionHtml, "j.doe") + "</p>";

+		assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName));

+

+		input = " @j.doe";

+		output = "<p>" + String.format(mentionHtml, "j.doe") + "</p>";

+		assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName));

+

+		input = "@j.doe.";

+		output = "<p>" + String.format(mentionHtml, "j.doe") + ".</p>";

+		assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName));

+

+		input = "To @j.doe: ask @jim.beam!";

+		output = "<p>To " + String.format(mentionHtml, "j.doe")

+				+ ": ask " + String.format(mentionHtml, "jim.beam") + "!</p>";

+		assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName));

+

+		input =   "@sta.rt\n"

+				+ "\n"

+				+ "User mentions in tickets are broken.\n"

+				+ "So:\n"

+				+ "@mc_guyver can fix this.\n"

+				+ "@j.doe, can you test after the fix by @m+guyver?\n"

+				+ "Please review this, @jim.beam!\n"

+				+ "Was reported by @jill and @j!doe from jane@doe yesterday.\n"

+				+ "\n"

+				+ "@jack.daniels can vote for john@wayne.name hopefully.\n"

+				+ "@en.de";

+		output =  "<p>"	+ String.format(mentionHtml, "sta.rt") + "</p>"

+				+ "<p>"	+ "User mentions in tickets are broken.<br/>"

+				+ "So:<br/>"

+				+ String.format(mentionHtml, "mc_guyver") + " can fix this.<br/>"

+				+ String.format(mentionHtml, "j.doe") + ", can you test after the fix by " + String.format(mentionHtml, "m+guyver") + "?<br/>"

+				+ "Please review this, " + String.format(mentionHtml, "jim.beam") + "!<br/>"

+				+ "Was reported by " + String.format(mentionHtml, "jill")

+				+ " and " + String.format(mentionHtml, "j!doe")

+				+ " from <a href=\"mailto:&#106;a&#110;&#x65;&#x40;&#x64;&#x6f;&#101;\">&#106;a&#110;&#x65;&#x40;&#x64;&#x6f;&#101;</a> yesterday." 

+				+ "</p>"

+				+ "<p>" + String.format(mentionHtml, "jack.daniels") + " can vote for "

+				+ "<a href=\"mailto:&#x6a;&#x6f;h&#110;&#x40;&#119;a&#121;&#110;&#101;.&#110;a&#x6d;&#101;\">&#x6a;&#x6f;h&#110;&#x40;&#119;a&#121;&#110;&#101;.&#110;a&#x6d;&#101;</a> hopefully.<br/>"

+				+ String.format(mentionHtml, "en.de")

+				+ "</p>";

+		assertEquals(output, MarkdownUtils.transformGFM(settings, input, repositoryName));

+

+	}

+

+

+

+

+	private MemorySettings getSettings() {

+		Map<String, Object> backingMap = new HashMap<String, Object>();

+

+		backingMap.put(Keys.web.canonicalUrl, "http://localhost");

+		backingMap.put(Keys.web.shortCommitIdLength, "7");

+

+		MemorySettings ms = new MemorySettings(backingMap);

+		return ms;

+	}

+}

diff --git a/src/test/java/com/gitblit/tests/SshKeysDispatcherTest.java b/src/test/java/com/gitblit/tests/SshKeysDispatcherTest.java
index 23e6179..4784e46 100644
--- a/src/test/java/com/gitblit/tests/SshKeysDispatcherTest.java
+++ b/src/test/java/com/gitblit/tests/SshKeysDispatcherTest.java
@@ -37,7 +37,7 @@
 		String result = testSshCommand("keys ls -L");
 		List<SshKey> keys = getKeyManager().getKeys(username);
 		assertEquals(String.format("There are %d keys!", keys.size()), 2, keys.size());
-		assertEquals(keys.get(0).getRawData() + "\n" + keys.get(1).getRawData(), result);
+		assertEquals(String.format("%s%n%s", keys.get(0).getRawData(), keys.get(1).getRawData()), result);
 	}
 
 	@Test
@@ -64,9 +64,9 @@
 		assertEquals(String.format("There are %d keys!", keys.size()), 0, keys.size());
 		try {
 			testSshCommand("keys ls -L");
-			assertTrue("Authentication worked without a public key?!", false);
+			fail("Authentication worked without a public key?!");
 		} catch (AssertionError e) {
-			assertTrue(true);
+			// expected
 		}
 	}
 
@@ -77,9 +77,9 @@
 		assertEquals(String.format("There are %d keys!", keys.size()), 0, keys.size());
 		try {
 			testSshCommand("keys ls -L");
-			assertTrue("Authentication worked without a public key?!", false);
+			fail("Authentication worked without a public key?!");
 		} catch (AssertionError e) {
-			assertTrue(true);
+			// expected
 		}
 	}
 
@@ -96,9 +96,9 @@
 		StringBuilder sb = new StringBuilder();
 		for (SshKey sk : keys) {
 			sb.append(sk.getRawData());
-			sb.append('\n');
+			sb.append(System.getProperty("line.separator", "\n"));
 		}
-		sb.setLength(sb.length() - 1);
+		sb.setLength(sb.length() - System.getProperty("line.separator", "\n").length());
 		assertEquals(sb.toString(), result);
 	}
 
diff --git a/src/test/java/com/gitblit/utils/SecureRandomTest.java b/src/test/java/com/gitblit/utils/SecureRandomTest.java
new file mode 100644
index 0000000..c4098c2
--- /dev/null
+++ b/src/test/java/com/gitblit/utils/SecureRandomTest.java
@@ -0,0 +1,33 @@
+package com.gitblit.utils;
+
+import static org.junit.Assert.*;
+
+import java.util.Arrays;
+
+import org.junit.Test;
+
+public class SecureRandomTest {
+
+	@Test
+	public void testRandomBytes() {
+		SecureRandom sr = new SecureRandom();
+		byte[] bytes1 = sr.randomBytes(10);
+		assertEquals(10, bytes1.length);
+		byte[] bytes2 = sr.randomBytes(10);
+		assertEquals(10, bytes2.length);
+		assertFalse(Arrays.equals(bytes1, bytes2));
+
+		assertEquals(0, sr.randomBytes(0).length);
+		assertEquals(200, sr.randomBytes(200).length);
+	}
+
+	@Test
+	public void testNextBytes() {
+		SecureRandom sr = new SecureRandom();
+		byte[] bytes1 = new byte[32];
+		sr.nextBytes(bytes1);
+		byte[] bytes2 = new byte[32];
+		sr.nextBytes(bytes2);
+		assertFalse(Arrays.equals(bytes1, bytes2));
+	}
+}
diff --git a/src/test/resources/ldap/users.conf b/src/test/resources/ldap/users.conf
index 7d1e319..a2390fa 100644
--- a/src/test/resources/ldap/users.conf
+++ b/src/test/resources/ldap/users.conf
@@ -10,7 +10,7 @@
 	displayName = Mrs. User Three
 	emailAddress = userthree@gitblit.com
 	accountType = LDAP
-	role = "#admin"
+	role = "#none"
 [user "userfive"]
 	password = "#externalAccount"
 	cookie = 220bafef069b8b399b2597644015b6b0f4667982
@@ -31,7 +31,7 @@
 	displayName = Mr. User Two
 	emailAddress = usertwo@gitblit.com
 	accountType = LDAP
-	role = "#admin"
+	role = "#none"
 [user "basic"]
 	password = MD5:f17aaabc20bfe045075927934fed52d2
 	cookie = dd94709528bb1c83d08f3088d4043f4742891f4f
@@ -63,6 +63,6 @@
 	user = userthree
 	user = userfour
 [team "Git Admins"]
-	role = "#none"
+	role = "#admin"
 	accountType = LOCAL
 	user = usertwo