Merge branch 'rcaa-master' into master.
diff --git a/src/main/distrib/data/defaults.properties b/src/main/distrib/data/defaults.properties
index 0c7d6cd..0416634 100644
--- a/src/main/distrib/data/defaults.properties
+++ b/src/main/distrib/data/defaults.properties
@@ -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
 #
@@ -1926,6 +1951,9 @@
 realm.ldap.uid = uid
 
 # 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/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/auth/LdapAuthProvider.java b/src/main/java/com/gitblit/auth/LdapAuthProvider.java
index b7efd4a..c31694b 100644
--- a/src/main/java/com/gitblit/auth/LdapAuthProvider.java
+++ b/src/main/java/com/gitblit/auth/LdapAuthProvider.java
@@ -39,6 +39,8 @@
 import com.gitblit.utils.ArrayUtils;
 import com.gitblit.utils.StringUtils;
 import com.unboundid.ldap.sdk.Attribute;
+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;
@@ -107,8 +109,14 @@
 		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();
+			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");
@@ -163,6 +171,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 +189,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 +240,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 +250,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,23 +265,26 @@
 	public UserModel authenticate(String username, char[] password) {
 		String simpleUsername = getSimpleUsername(username);
 
-		LDAPConnection ldapConnection = getLdapConnection();
-		if (ldapConnection != null) {
+		LdapConnection ldapConnection = new LdapConnection();
+		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}))");
@@ -348,7 +295,7 @@
 					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;
@@ -374,6 +321,8 @@
 
 							if (!supportsTeamMembershipChanges()) {
 								for (TeamModel userTeam : user.teams) {
+									// Is this an administrative team?
+									setAdminAttribute(userTeam);
 									updateTeam(userTeam);
 								}
 							}
@@ -404,10 +353,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 +362,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 +432,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
@@ -479,7 +449,7 @@
 			groupMemberPattern = StringUtils.replace(groupMemberPattern, "${" + userAttribute.getName() + "}", 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 +466,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 +481,7 @@
 					TeamModel teamModel = userManager.getTeamModel(teamName);
 					if (teamModel == null) {
 						teamModel = createTeamFromLdap(teamEntry);
+						setAdminAttribute(teamModel);
 						userManager.updateTeamModel(teamModel);
 					}
 				}
@@ -519,6 +490,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 +522,21 @@
 		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.
@@ -585,7 +554,7 @@
 	}
 
 	// From: https://www.owasp.org/index.php/Preventing_LDAP_Injection_in_Java
-	public static final String escapeLDAPSearchFilter(String filter) {
+	private static final String escapeLDAPSearchFilter(String filter) {
 		StringBuilder sb = new StringBuilder();
 		for (int i = 0; i < filter.length(); i++) {
 			char curChar = filter.charAt(i);
@@ -625,4 +594,225 @@
 		}
 	}
 
+
+
+	private class LdapConnection {
+		private LDAPConnection conn;
+		private SimpleBindRequest currentBindRequest;
+		private SimpleBindRequest managerBindRequest;
+		private SimpleBindRequest userBindRequest;
+
+
+		public LdapConnection() {
+			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);
+		}
+
+
+		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;
+		}
+
+
+		void close() {
+			if (conn != null) {
+				conn.close();
+			}
+		}
+
+
+		SearchResult search(SearchRequest request) {
+			try {
+				return conn.search(request);
+			} catch (LDAPSearchException e) {
+				logger.error("Problem Searching LDAP [{}]",  e.getResultCode());
+				return e.getSearchResult();
+			}
+		}
+
+
+		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;
+			}
+		}
+
+
+
+		/**
+		 * Bind using the manager credentials set in realm.ldap.username and ..password
+		 * @return A bind result, or null if binding failed.
+		 */
+		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.
+		 */
+		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;
+		}
+
+
+		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;
+		}
+
+
+		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;
+		}
+
+
+
+		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/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/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/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/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/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/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/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/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..b7a77fc 100644
--- a/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java
+++ b/src/test/java/com/gitblit/tests/LdapAuthenticationTest.java
@@ -16,17 +16,26 @@
  */
 package com.gitblit.tests;
 
+import static org.junit.Assume.*;
+
 import java.io.File;
-import java.io.FileInputStream;
+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.Test;
 import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
 
 import com.gitblit.Constants.AccountType;
 import com.gitblit.IStoredSettings;
@@ -43,9 +52,24 @@
 import com.gitblit.utils.XssFilter.AllowXssFilter;
 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.SearchResult;
 import com.unboundid.ldap.sdk.SearchScope;
+import com.unboundid.ldap.sdk.SimpleBindRequest;
 import com.unboundid.ldif.LDIFReader;
 
 /**
@@ -55,19 +79,71 @@
  * @author jcrygier
  *
  */
+@RunWith(Parameterized.class)
 public class LdapAuthenticationTest extends GitblitUnitTest {
-    @Rule
-    public TemporaryFolder folder = new TemporaryFolder();
 
 	private 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";
+	private 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";
 
-    private File usersConf;
 
-    private LdapAuthProvider ldap;
+	/**
+	 * 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.
+	 *
+	 */
+	enum AuthMode {
+		ANONYMOUS(1389),
+		DS_MANAGER(2389),
+		USR_MANAGER(3389);
 
-	static int ldapPort = 1389;
 
-	private static InMemoryDirectoryServer ds;
+		private int ldapPort;
+		private InMemoryDirectoryServer ds;
+		private InMemoryDirectoryServerSnapshot dsSnapshot;
+
+		AuthMode(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 restoreSnapshot() {
+			ds.restoreSnapshot(dsSnapshot);
+		}
+	};
+
+
+
+	@Parameter
+	public AuthMode authMode;
+
+	@Rule
+	public TemporaryFolder folder = new TemporaryFolder();
+
+	private File usersConf;
+
+
+
+	private LdapAuthProvider ldap;
 
 	private IUserManager userManager;
 
@@ -75,21 +151,82 @@
 
 	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();
+	/**
+	 * 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 init() throws Exception {
+		InMemoryDirectoryServer ds;
+		InMemoryDirectoryServerConfig config = createInMemoryLdapServerConfig(AuthMode.ANONYMOUS);
+		config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.ANONYMOUS.ldapPort()));
+		ds = createInMemoryLdapServer(config);
+		AuthMode.ANONYMOUS.setDS(ds);
+
+
+		config = createInMemoryLdapServerConfig(AuthMode.DS_MANAGER);
+		config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.DS_MANAGER.ldapPort()));
+		config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class));
+		ds = createInMemoryLdapServer(config);
+		AuthMode.DS_MANAGER.setDS(ds);
+
+
+		config = createInMemoryLdapServerConfig(AuthMode.USR_MANAGER);
+		config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("default", AuthMode.USR_MANAGER.ldapPort()));
+		config.setAuthenticationRequiredOperationTypes(EnumSet.allOf(OperationType.class));
+		ds = createInMemoryLdapServer(config);
+		AuthMode.USR_MANAGER.setDS(ds);
+
+	}
+
+	@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);
+
+		config.addInMemoryOperationInterceptor(new AccessInterceptor(authMode));
+
+		return config;
+	}
+
+
+
 	@Before
-	public void init() throws Exception {
-		ds.clear();
-		ds.importFromLDIF(true, new LDIFReader(new FileInputStream(RESOURCE_DIR + "sampledata.ldif")));
+	public void setup() throws Exception {
+		authMode.restoreSnapshot();
+
 		usersConf = folder.newFile("users.conf");
 		FileUtils.copyFile(new File(RESOURCE_DIR + "users.conf"), usersConf);
 		settings = getSettings();
@@ -117,15 +254,30 @@
 	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");
+		switch(authMode) {
+		case ANONYMOUS:
+			backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort());
+			backingMap.put(Keys.realm.ldap.username, "");
+			backingMap.put(Keys.realm.ldap.password, "");
+			break;
+		case DS_MANAGER:
+			backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort());
+			backingMap.put(Keys.realm.ldap.username, DIRECTORY_MANAGER);
+			backingMap.put(Keys.realm.ldap.password, "password");
+			break;
+		case USR_MANAGER:
+			backingMap.put(Keys.realm.ldap.server, "ldap://localhost:" + authMode.ldapPort());
+			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, "OU=Users,OU=UserControl,OU=MyOrganization,DC=MyDomain");
+		backingMap.put(Keys.realm.ldap.accountBase, ACCOUNT_BASE);
 		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.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");
@@ -136,13 +288,14 @@
 		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 +305,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 +445,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 +459,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 +588,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 +707,12 @@
 		assertNull(userOneModelFailedAuth);
 	}
 
+
+	private InMemoryDirectoryServer getDS()
+	{
+		return authMode.getDS();
+	}
+
 	private int countLdapUsersInUserManager() {
 		int ldapAccountCount = 0;
 		for (UserModel userModel : userManager.getAllUsers()) {
@@ -296,4 +733,120 @@
 		return ldapAccountCount;
 	}
 
+
+
+
+	/**
+	 * 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.
+	 *
+	 */
+	private 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/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/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