From c44dd099a432094a12131cf60dfc8a19f5aa8101 Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Wed, 13 Nov 2013 17:56:50 -0500
Subject: [PATCH] Implement mirror executor (issue-5)

---
 src/main/java/com/gitblit/wicket/pages/RepositoryPage.java          |    9 +
 src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.html      |    2 
 src/main/java/com/gitblit/git/GitblitReceivePack.java               |    8 +
 src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.java      |    7 +
 src/main/java/com/gitblit/MirrorExecutor.java                       |  169 ++++++++++++++++++++++++++++
 src/main/distrib/data/gitblit.properties                            |   28 ++++
 src/main/java/com/gitblit/GitBlit.java                              |   21 +++
 src/main/java/com/gitblit/wicket/GitBlitWebApp.properties           |    5 
 src/main/resources/mirror_16x16.png                                 |    0 
 src/site/features.mkd                                               |    1 
 src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.java |    6 +
 src/main/java/com/gitblit/client/IndicatorsRenderer.java            |    8 +
 src/main/java/com/gitblit/models/UserModel.java                     |    2 
 src/main/java/com/gitblit/models/RepositoryModel.java               |    1 
 src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.html |    1 
 src/main/java/com/gitblit/utils/JGitUtils.java                      |   50 ++++++++
 src/main/java/com/gitblit/models/TeamModel.java                     |    2 
 releases.moxie                                                      |    8 +
 build.xml                                                           |    1 
 src/main/java/com/gitblit/wicket/pages/RepositoryPage.html          |    4 
 src/test/java/com/gitblit/tests/PermissionsTest.java                |   18 +++
 21 files changed, 343 insertions(+), 8 deletions(-)

diff --git a/build.xml b/build.xml
index 57b7ca2..e4af1ef 100644
--- a/build.xml
+++ b/build.xml
@@ -451,6 +451,7 @@
 			<resource file="${project.resources.dir}/commit_merge_16x16.png" />
 			<resource file="${project.resources.dir}/commit_divide_16x16.png" />
 			<resource file="${project.resources.dir}/star_16x16.png" />
+			<resource file="${project.resources.dir}/mirror_16x16.png" />
 			<resource file="${project.resources.dir}/blank.png" />
 			<resource file="${project.src.dir}/log4j.properties" />
 			<resource>
diff --git a/releases.moxie b/releases.moxie
index 1832284..a3b5ee4 100644
--- a/releases.moxie
+++ b/releases.moxie
@@ -16,6 +16,7 @@
 	- Fix error on generating activity page when there is no activity
 	- Fix raw page content type of binaries when running behind a reverse proxy
     changes:
+	- Gitblit now rejects pushes to mirror repositories (issue-5)
 	- Personal repository prefix (~) is now configurable (issue-265)
 	- Reversed line links in blob view (issue-309)
 	- Dashboard and Activity pages now obey the web.generateActivityGraph setting (issue-310)
@@ -24,11 +25,12 @@
 	- Change the WAR baseFolder context parameter to a JNDI env-entry to improve enterprise deployments
 	- Removed internal Gitblit ref exclusions in the upload pack
 	- Removed "show readme" setting in favor of automatic detection
-	- Support plain text "readme" files
+	- Support plain text, markdown, confluence, mediawiki, textile, tracwiki, or twiki "readme" files
 	- Determine best commit id (e.g. "master") for the tree and docs pages and use that in links
-	- By default GO will now bind to all interfaces for both http and https connectors.  This simplifies setup for first-time users.
+	- By default GO will now bind to all interfaces for both http and https connectors.  This simplifies setup for first-time users.	
 	- Removed docs indicator on the repositories page
     additions:
+	- Added an optional MirrorExecutor which will periodically fetch ref updates from source repositories for mirrors (issue-5).  Repositories must be manually cloned using native git and "--mirror".
 	- Added branch graph image servlet based on EGit's branch graph renderer (issue-194)
 	- Added option to render Markdown commit messages (issue-203)
 	- Added setting to control creating a repository as --shared on Unix servers (issue-263)
@@ -47,7 +49,9 @@
     settings:
     - { name: 'git.createRepositoriesShared', defaultValue: 'false' }
     - { name: 'git.allowAnonymousPushes', defaultValue: 'false' }
+	- { name: 'git.enableMirroring', defaultValue: 'false' }
 	- { name: 'git.defaultAccessRestriction', defaultValue: 'PUSH' }
+	- { name: 'git.mirrorPeriod', defaultValue: '30 mins' }
 	- { name: 'web.commitMessageRenderer', defaultValue: 'plain' }
 	- { name: 'web.showBranchGraph', defaultValue: 'true' }
 	- { name: 'server.redirectToHttpsPort', defaultValue: 'true' }
diff --git a/src/main/distrib/data/gitblit.properties b/src/main/distrib/data/gitblit.properties
index a791c1f..2823c4d 100644
--- a/src/main/distrib/data/gitblit.properties
+++ b/src/main/distrib/data/gitblit.properties
@@ -276,6 +276,34 @@
 # SINCE 1.2.0
 git.defaultGarbageCollectionPeriod = 7
 
+# Gitblit can automatically fetch ref updates for a properly configured mirror
+# repository.
+#
+# Requirements:
+# 1. you must manually clone the repository using native git
+#    git clone --mirror git://somewhere.com/myrepo.git
+# 2. the "origin" remote must be the mirror source
+# 3. the "origin" repository must be accessible without authentication OR the
+#    credentials must be embedded in the origin url (not recommended)
+#
+# Notes:
+# 1. "origin" SSH urls are untested and not likely to work
+# 2. mirrors cloned while Gitblit is running are likely to require clearing the
+#    gitblit cache (link on the repositories page of an administrator account)
+# 3. Gitblit will automatically repair any invalid fetch refspecs with a "//"
+#    sequence.
+#
+# SINCE 1.4.0
+# RESTART REQUIRED
+git.enableMirroring = false
+
+# Specify the period between update checks for mirrored repositories.
+# The shortest period you may specify between mirror update checks is 5 mins.
+#
+# SINCE 1.4.0
+# RESTART REQUIRED
+git.mirrorPeriod = 30 mins
+
 # Number of bytes of a pack file to load into memory in a single read operation.
 # This is the "page size" of the JGit buffer cache, used for all pack access
 # operations. All disk IO occurs as single window reads. Setting this too large
diff --git a/src/main/java/com/gitblit/GitBlit.java b/src/main/java/com/gitblit/GitBlit.java
index f313b6e..a0e8b0a 100644
--- a/src/main/java/com/gitblit/GitBlit.java
+++ b/src/main/java/com/gitblit/GitBlit.java
@@ -164,7 +164,7 @@
 
 	private final Logger logger = LoggerFactory.getLogger(GitBlit.class);
 
-	private final ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(5);
+	private final ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(10);
 
 	private final List<FederationModel> federationRegistrations = Collections
 			.synchronizedList(new ArrayList<FederationModel>());
@@ -206,6 +206,8 @@
 	private LuceneExecutor luceneExecutor;
 
 	private GCExecutor gcExecutor;
+
+	private MirrorExecutor mirrorExecutor;
 
 	private TimeZone timezone;
 
@@ -2035,6 +2037,7 @@
 			model.origin = config.getString("remote", "origin", "url");
 			if (model.origin != null) {
 				model.origin = model.origin.replace('\\', '/');
+				model.isMirror = config.getBoolean("remote", "origin", "mirror", false);
 			}
 			model.preReceiveScripts = new ArrayList<String>(Arrays.asList(config.getStringList(
 					Constants.CONFIG_GITBLIT, null, "preReceiveScript")));
@@ -3505,6 +3508,7 @@
 		mailExecutor = new MailExecutor(settings);
 		luceneExecutor = new LuceneExecutor(settings, repositoriesFolder);
 		gcExecutor = new GCExecutor(settings);
+		mirrorExecutor = new MirrorExecutor(settings);
 
 		// initialize utilities
 		String prefix = settings.getString(Keys.git.userRepositoryPrefix, "~");
@@ -3544,6 +3548,7 @@
 		configureMailExecutor();
 		configureLuceneIndexing();
 		configureGarbageCollector();
+		configureMirrorExecutor();
 		if (startFederation) {
 			configureFederation();
 		}
@@ -3592,6 +3597,19 @@
 			}
 			logger.info(MessageFormat.format("Next scheculed GC scan is in {0}", when));
 			scheduledExecutor.scheduleAtFixedRate(gcExecutor, delay, 60*24, TimeUnit.MINUTES);
+		}
+	}
+
+	protected void configureMirrorExecutor() {
+		if (mirrorExecutor.isReady()) {
+			int mins = TimeUtils.convertFrequencyToMinutes(settings.getString(Keys.git.mirrorPeriod, "30 mins"));
+			if (mins < 5) {
+				mins = 5;
+			}
+			int delay = 1;
+			scheduledExecutor.scheduleAtFixedRate(mirrorExecutor, delay, mins,  TimeUnit.MINUTES);
+			logger.info("Mirror executor is scheduled to fetch updates every {} minutes.", mins);
+			logger.info("Next scheduled mirror fetch is in {} minutes", delay);
 		}
 	}
 
@@ -3864,6 +3882,7 @@
 		scheduledExecutor.shutdownNow();
 		luceneExecutor.close();
 		gcExecutor.close();
+		mirrorExecutor.close();
 		if (fanoutService != null) {
 			fanoutService.stop();
 		}
diff --git a/src/main/java/com/gitblit/MirrorExecutor.java b/src/main/java/com/gitblit/MirrorExecutor.java
new file mode 100644
index 0000000..21c194f
--- /dev/null
+++ b/src/main/java/com/gitblit/MirrorExecutor.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit;
+
+import java.text.MessageFormat;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.transport.FetchResult;
+import org.eclipse.jgit.transport.RemoteConfig;
+import org.eclipse.jgit.transport.TrackingRefUpdate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.UserModel;
+import com.gitblit.utils.JGitUtils;
+
+/**
+ * The Mirror executor handles periodic fetching of mirrored repositories.
+ *
+ * @author James Moger
+ *
+ */
+public class MirrorExecutor implements Runnable {
+
+	private final Logger logger = LoggerFactory.getLogger(MirrorExecutor.class);
+
+	private final Set<String> repairAttempted = Collections.synchronizedSet(new HashSet<String>());
+
+	private final IStoredSettings settings;
+
+	private AtomicBoolean running = new AtomicBoolean(false);
+
+	private AtomicBoolean forceClose = new AtomicBoolean(false);
+
+	private final UserModel gitblitUser;
+
+	public MirrorExecutor(IStoredSettings settings) {
+		this.settings = settings;
+		this.gitblitUser = new UserModel("gitblit");
+		this.gitblitUser.displayName = "Gitblit";
+	}
+
+	public boolean isReady() {
+		return settings.getBoolean(Keys.git.enableMirroring, false);
+	}
+
+	public boolean isRunning() {
+		return running.get();
+	}
+
+	public void close() {
+		forceClose.set(true);
+	}
+
+	@Override
+	public void run() {
+		if (!isReady()) {
+			return;
+		}
+
+		running.set(true);
+
+		for (String repositoryName : GitBlit.self().getRepositoryList()) {
+			if (forceClose.get()) {
+				break;
+			}
+			if (GitBlit.self().isCollectingGarbage(repositoryName)) {
+				logger.debug("mirror is skipping {} garbagecollection", repositoryName);
+				continue;
+			}
+			RepositoryModel model = null;
+			Repository repository = null;
+			try {
+				model = GitBlit.self().getRepositoryModel(repositoryName);
+				if (!model.isMirror && !model.isBare) {
+					// repository must be a valid bare git mirror
+					logger.debug("mirror is skipping {} !mirror !bare", repositoryName);
+					continue;
+				}
+
+				repository = GitBlit.self().getRepository(repositoryName);
+				if (repository == null) {
+					logger.warn(MessageFormat.format("MirrorExecutor is missing repository {0}?!?", repositoryName));
+					continue;
+				}
+
+				// automatically repair (some) invalid fetch ref specs
+				if (!repairAttempted.contains(repositoryName)) {
+					repairAttempted.add(repositoryName);
+					JGitUtils.repairFetchSpecs(repository);
+				}
+
+				// find the first mirror remote - there should only be one
+				StoredConfig rc = repository.getConfig();
+				RemoteConfig mirror = null;
+				List<RemoteConfig> configs = RemoteConfig.getAllRemoteConfigs(rc);
+				for (RemoteConfig config : configs) {
+					if (config.isMirror()) {
+						mirror = config;
+						break;
+					}
+				}
+
+				if (mirror == null) {
+					// repository does not have a mirror remote
+					logger.debug("mirror is skipping {} no mirror remote found", repositoryName);
+					continue;
+				}
+
+				logger.debug("checking {} remote {} for ref updates", repositoryName, mirror.getName());
+				final boolean testing = false;
+				Git git = new Git(repository);
+				FetchResult result = git.fetch().setRemote(mirror.getName()).setDryRun(testing).call();
+				Collection<TrackingRefUpdate> refUpdates = result.getTrackingRefUpdates();
+				if (refUpdates.size() > 0) {
+					for (TrackingRefUpdate ru : refUpdates) {
+						StringBuilder sb = new StringBuilder();
+						sb.append("updated mirror ");
+						sb.append(repositoryName);
+						sb.append(" ");
+						sb.append(ru.getRemoteName());
+						sb.append(" -> ");
+						sb.append(ru.getLocalName());
+						if (ru.getResult() == Result.FORCED) {
+							sb.append(" (forced)");
+						}
+						sb.append(" ");
+						sb.append(ru.getOldObjectId() == null ? "" : ru.getOldObjectId().abbreviate(7).name());
+						sb.append("..");
+						sb.append(ru.getNewObjectId() == null ? "" : ru.getNewObjectId().abbreviate(7).name());
+						logger.info(sb.toString());
+					}
+				}
+			} catch (Exception e) {
+				logger.error("Error updating mirror " + repositoryName, e);
+			} finally {
+				// cleanup
+				if (repository != null) {
+					repository.close();
+				}
+			}
+		}
+
+		running.set(false);
+	}
+}
diff --git a/src/main/java/com/gitblit/client/IndicatorsRenderer.java b/src/main/java/com/gitblit/client/IndicatorsRenderer.java
index 5b61df6..5883ab0 100644
--- a/src/main/java/com/gitblit/client/IndicatorsRenderer.java
+++ b/src/main/java/com/gitblit/client/IndicatorsRenderer.java
@@ -56,6 +56,8 @@
 
 	private final ImageIcon sparkleshareIcon;
 
+	private final ImageIcon mirrorIcon;
+
 	public IndicatorsRenderer() {
 		super(new FlowLayout(FlowLayout.RIGHT, 1, 0));
 		blankIcon = new ImageIcon(getClass().getResource("/blank.png"));
@@ -67,6 +69,7 @@
 		federatedIcon = new ImageIcon(getClass().getResource("/federated_16x16.png"));
 		forkIcon = new ImageIcon(getClass().getResource("/commit_divide_16x16.png"));
 		sparkleshareIcon = new ImageIcon(getClass().getResource("/star_16x16.png"));
+		mirrorIcon = new ImageIcon(getClass().getResource("/mirror_16x16.png"));
 	}
 
 	@Override
@@ -85,6 +88,11 @@
 				tooltip.append(Translation.get("gb.isSparkleshared")).append("<br/>");
 				add(icon);
 			}
+			if (model.isMirror) {
+				JLabel icon = new JLabel(mirrorIcon);
+				tooltip.append(Translation.get("gb.isMirror")).append("<br/>");
+				add(icon);
+			}
 			if (model.isFork()) {
 				JLabel icon = new JLabel(forkIcon);
 				tooltip.append(Translation.get("gb.isFork")).append("<br/>");
diff --git a/src/main/java/com/gitblit/git/GitblitReceivePack.java b/src/main/java/com/gitblit/git/GitblitReceivePack.java
index e6ff572..ba200b2 100644
--- a/src/main/java/com/gitblit/git/GitblitReceivePack.java
+++ b/src/main/java/com/gitblit/git/GitblitReceivePack.java
@@ -120,6 +120,14 @@
 	@Override
 	public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
 
+		if (repository.isMirror) {
+			// repository is a mirror
+			for (ReceiveCommand cmd : commands) {
+				sendRejection(cmd, "Gitblit does not allow pushes to \"{0}\" because it is a mirror!", repository.name);
+			}
+			return;
+		}
+
 		if (repository.isFrozen) {
 			// repository is frozen/readonly
 			for (ReceiveCommand cmd : commands) {
diff --git a/src/main/java/com/gitblit/models/RepositoryModel.java b/src/main/java/com/gitblit/models/RepositoryModel.java
index 8885465..40a5acd 100644
--- a/src/main/java/com/gitblit/models/RepositoryModel.java
+++ b/src/main/java/com/gitblit/models/RepositoryModel.java
@@ -65,6 +65,7 @@
 	public boolean skipSummaryMetrics;
 	public String frequency;
 	public boolean isBare;
+	public boolean isMirror;
 	public String origin;
 	public String HEAD;
 	public List<String> availableRefs;
diff --git a/src/main/java/com/gitblit/models/TeamModel.java b/src/main/java/com/gitblit/models/TeamModel.java
index 54f194b..a192828 100644
--- a/src/main/java/com/gitblit/models/TeamModel.java
+++ b/src/main/java/com/gitblit/models/TeamModel.java
@@ -206,7 +206,7 @@
 
 		// determine maximum permission for the repository
 		final AccessPermission maxPermission =
-				(repository.isFrozen || !repository.isBare) ?
+				(repository.isFrozen || !repository.isBare || repository.isMirror) ?
 						AccessPermission.CLONE : AccessPermission.REWIND;
 
 		if (AccessRestrictionType.NONE.equals(repository.accessRestriction)) {
diff --git a/src/main/java/com/gitblit/models/UserModel.java b/src/main/java/com/gitblit/models/UserModel.java
index b4fdb66..446db3a 100644
--- a/src/main/java/com/gitblit/models/UserModel.java
+++ b/src/main/java/com/gitblit/models/UserModel.java
@@ -292,7 +292,7 @@
 
 		// determine maximum permission for the repository
 		final AccessPermission maxPermission =
-				(repository.isFrozen || !repository.isBare) ?
+				(repository.isFrozen || !repository.isBare || repository.isMirror) ?
 						AccessPermission.CLONE : AccessPermission.REWIND;
 
 		if (AccessRestrictionType.NONE.equals(repository.accessRestriction)) {
diff --git a/src/main/java/com/gitblit/utils/JGitUtils.java b/src/main/java/com/gitblit/utils/JGitUtils.java
index be2860d..5584fb5 100644
--- a/src/main/java/com/gitblit/utils/JGitUtils.java
+++ b/src/main/java/com/gitblit/utils/JGitUtils.java
@@ -2096,4 +2096,54 @@
 		}
 		return StringUtils.decodeString(content);
 	}
+
+	/**
+	 * Automatic repair of (some) invalid refspecs.  These are the result of a
+	 * bug in JGit cloning where a double forward-slash was injected.  :(
+	 *
+	 * @param repository
+	 * @return true, if the refspecs were repaired
+	 */
+	public static boolean repairFetchSpecs(Repository repository) {
+		StoredConfig rc = repository.getConfig();
+
+		// auto-repair broken fetch ref specs
+		for (String name : rc.getSubsections("remote")) {
+			int invalidSpecs = 0;
+			int repairedSpecs = 0;
+			List<String> specs = new ArrayList<String>();
+			for (String spec : rc.getStringList("remote", name, "fetch")) {
+				try {
+					RefSpec rs = new RefSpec(spec);
+					// valid spec
+					specs.add(spec);
+				} catch (IllegalArgumentException e) {
+					// invalid spec
+					invalidSpecs++;
+					if (spec.contains("//")) {
+						// auto-repair this known spec bug
+						spec = spec.replace("//", "/");
+						specs.add(spec);
+						repairedSpecs++;
+					}
+				}
+			}
+
+			if (invalidSpecs == repairedSpecs && repairedSpecs > 0) {
+				// the fetch specs were automatically repaired
+				rc.setStringList("remote", name, "fetch", specs);
+				try {
+					rc.save();
+					rc.load();
+					LOGGER.debug("repaired {} invalid fetch refspecs for {}", repairedSpecs, repository.getDirectory());
+					return true;
+				} catch (Exception e) {
+					LOGGER.error(null, e);
+				}
+			} else if (invalidSpecs > 0) {
+				LOGGER.error("mirror executor found {} invalid fetch refspecs for {}", invalidSpecs, repository.getDirectory());
+			}
+		}
+		return false;
+	}
 }
diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
index 526093a..feaa9c6 100644
--- a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
+++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
@@ -504,4 +504,7 @@
 gb.anonymousUser= anonymous
 gb.commitMessageRenderer = commit message renderer
 gb.diffStat = {0} insertions & {1} deletions
-gb.home = home
\ No newline at end of file
+gb.home = home
+gb.isMirror = this repository is a mirror
+gb.mirrorOf = mirror of {0}
+gb.mirrorWarning = this repository is a mirror and can not receive pushes
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html
index 1af9127..0acc6db 100644
--- a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html
+++ b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html
@@ -62,6 +62,10 @@
 		<wicket:fragment wicket:id="originFragment">
 			<p class="originRepository"><wicket:message key="gb.forkedFrom">[forked from]</wicket:message> <span wicket:id="originRepository">[origin repository]</span></p>
 		</wicket:fragment>
+
+		<wicket:fragment wicket:id="mirrorFragment">
+			<p class="originRepository"><span wicket:id="originRepository">[origin repository]</span></p>
+		</wicket:fragment>
 				
 	</wicket:extend>
 </body>
diff --git a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java
index d0d801e..70a2b9e 100644
--- a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java
+++ b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java
@@ -260,7 +260,14 @@
 		// indicate origin repository
 		RepositoryModel model = getRepositoryModel();
 		if (StringUtils.isEmpty(model.originRepository)) {
-			add(new Label("originRepository").setVisible(false));
+			if (model.isMirror) {
+				Fragment mirrorFrag = new Fragment("originRepository", "mirrorFragment", this);
+				Label lbl = new Label("originRepository", MessageFormat.format(getString("gb.mirrorOf"), "<b>" + model.origin + "</b>"));
+				mirrorFrag.add(lbl.setEscapeModelStrings(false));
+				add(mirrorFrag);
+			} else {
+				add(new Label("originRepository").setVisible(false));
+			}
 		} else {
 			RepositoryModel origin = GitBlit.self().getRepositoryModel(model.originRepository);
 			if (origin == null) {
diff --git a/src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.html b/src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.html
index 54c1e92..d5a87d3 100644
--- a/src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.html
+++ b/src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.html
@@ -39,6 +39,7 @@
 				<span wicket:id="repositoryLinks"></span>
 				<div>
 					<img class="inlineIcon" wicket:id="sparkleshareIcon" />
+					<img class="inlineIcon" wicket:id="mirrorIcon" />
 					<img class="inlineIcon" wicket:id="frozenIcon" />
 					<img class="inlineIcon" wicket:id="federatedIcon" />
         						
diff --git a/src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.java b/src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.java
index f6c80bd..ed5780f 100644
--- a/src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.java
+++ b/src/main/java/com/gitblit/wicket/panels/ProjectRepositoryPanel.java
@@ -87,6 +87,12 @@
 			add(WicketUtils.newClearPixel("sparkleshareIcon").setVisible(false));
 		}
 
+		if (entry.isMirror) {
+			add(WicketUtils.newImage("mirrorIcon", "mirror_16x16.png", localizer.getString("gb.isMirror", parent)));
+		} else {
+			add(WicketUtils.newClearPixel("mirrorIcon").setVisible(false));
+		}
+
 		if (entry.isFrozen) {
 			add(WicketUtils.newImage("frozenIcon", "cold_16x16.png", localizer.getString("gb.isFrozen", parent)));
 		} else {
diff --git a/src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.html b/src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.html
index e080103..0cf3ef2 100644
--- a/src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.html
+++ b/src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.html
@@ -89,7 +89,7 @@
         <td class="left" style="padding-left:3px;" ><b><span class="repositorySwatch" wicket:id="repositorySwatch"></span></b> <span style="padding-left:3px;" wicket:id="repositoryName">[repository name]</span></td>
         <td class="hidden-phone"><span class="list" wicket:id="repositoryDescription">[repository description]</span></td>
         <td class="hidden-tablet hidden-phone author"><span wicket:id="repositoryOwner">[repository owner]</span></td>
-        <td class="hidden-phone" style="text-align: right;padding-right:10px;"><img class="inlineIcon" wicket:id="sparkleshareIcon" /><img class="inlineIcon" wicket:id="forkIcon" /><img class="inlineIcon" wicket:id="frozenIcon" /><img class="inlineIcon" wicket:id="federatedIcon" /><img class="inlineIcon" wicket:id="accessRestrictionIcon" /></td>
+        <td class="hidden-phone" style="text-align: right;padding-right:10px;"><img class="inlineIcon" wicket:id="sparkleshareIcon" /><img class="inlineIcon" wicket:id="mirrorIcon" /><img class="inlineIcon" wicket:id="forkIcon" /><img class="inlineIcon" wicket:id="frozenIcon" /><img class="inlineIcon" wicket:id="federatedIcon" /><img class="inlineIcon" wicket:id="accessRestrictionIcon" /></td>
         <td><span wicket:id="repositoryLastChange">[last change]</span></td>
         <td class="hidden-phone" style="text-align: right;padding-right:15px;"><span style="font-size:0.8em;" wicket:id="repositorySize">[repository size]</span></td>
         <td class="rightAlign">
diff --git a/src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.java b/src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.java
index cb27150..9de387a 100644
--- a/src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.java
+++ b/src/main/java/com/gitblit/wicket/panels/RepositoriesPanel.java
@@ -243,6 +243,13 @@
 					row.add(WicketUtils.newClearPixel("sparkleshareIcon").setVisible(false));
 				}
 
+				if (entry.isMirror) {
+					row.add(WicketUtils.newImage("mirrorIcon", "mirror_16x16.png",
+							getString("gb.isMirror")));
+				} else {
+					row.add(WicketUtils.newClearPixel("mirrorIcon").setVisible(false));
+				}
+
 				if (entry.isFork()) {
 					row.add(WicketUtils.newImage("forkIcon", "commit_divide_16x16.png",
 							getString("gb.isFork")));
diff --git a/src/main/resources/mirror_16x16.png b/src/main/resources/mirror_16x16.png
new file mode 100644
index 0000000..a0c25e9
--- /dev/null
+++ b/src/main/resources/mirror_16x16.png
Binary files differ
diff --git a/src/site/features.mkd b/src/site/features.mkd
index 31ad007..c426abf 100644
--- a/src/site/features.mkd
+++ b/src/site/features.mkd
@@ -19,6 +19,7 @@
 - Optional feature to allow users to create personal repositories
 - Optional feature to fork a repository to a personal repository
 - Optional feature to create a repository on push
+- Optional feature to automatically fetch ref updates for repository mirrors
 - *Experimental* built-in Garbage Collection
 - Ability to federate with one or more other Gitblit instances
 - RSS/JSON RPC interface
diff --git a/src/test/java/com/gitblit/tests/PermissionsTest.java b/src/test/java/com/gitblit/tests/PermissionsTest.java
index 942811d..42ef993 100644
--- a/src/test/java/com/gitblit/tests/PermissionsTest.java
+++ b/src/test/java/com/gitblit/tests/PermissionsTest.java
@@ -2878,4 +2878,22 @@
 		assertEquals("user has wrong permission!", AccessPermission.CLONE, user.getRepositoryPermission(repo).permission);
 		assertEquals("team has wrong permission!", AccessPermission.CLONE, team.getRepositoryPermission(repo).permission);
 	}
+
+	@Test
+	public void testIsMirror() throws Exception {
+		RepositoryModel repo = new RepositoryModel("somerepo.git", null, null, new Date());
+		repo.authorizationControl = AuthorizationControl.NAMED;
+		repo.accessRestriction = AccessRestrictionType.NONE;
+
+		UserModel user = new UserModel("test");
+		TeamModel team = new TeamModel("team");
+
+		assertEquals("user has wrong permission!", AccessPermission.REWIND, user.getRepositoryPermission(repo).permission);
+		assertEquals("team has wrong permission!", AccessPermission.REWIND, team.getRepositoryPermission(repo).permission);
+
+		// set repo to be a mirror, pushes prohibited
+		repo.isMirror = true;
+		assertEquals("user has wrong permission!", AccessPermission.CLONE, user.getRepositoryPermission(repo).permission);
+		assertEquals("team has wrong permission!", AccessPermission.CLONE, team.getRepositoryPermission(repo).permission);
+	}
 }

--
Gitblit v1.9.1