package com.atlassian.labs.speakeasy.git;
import com.atlassian.event.api.EventListener;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.labs.speakeasy.event.*;
import com.atlassian.labs.speakeasy.manager.PluginOperationFailedException;
import com.atlassian.labs.speakeasy.manager.ZipWriter;
import com.atlassian.labs.speakeasy.util.BundleUtil;
import com.atlassian.labs.speakeasy.util.ExtensionValidate;
import com.atlassian.labs.speakeasy.util.exec.KeyedSyncExecutor;
import com.atlassian.labs.speakeasy.util.exec.Operation;
import com.atlassian.sal.api.ApplicationProperties;
import com.google.common.collect.MapMaker;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.ResetCommand;
import org.eclipse.jgit.api.Status;
import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
import org.eclipse.jgit.api.errors.NoFilepatternException;
import org.eclipse.jgit.api.errors.NoHeadException;
import org.eclipse.jgit.api.errors.NoMessageException;
import org.eclipse.jgit.api.errors.WrongRepositoryStateException;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
import static com.atlassian.labs.speakeasy.util.BundleUtil.findBundleForPlugin;
import static com.atlassian.labs.speakeasy.util.BundleUtil.getPublicBundlePathsRecursive;
import static com.atlassian.labs.speakeasy.util.ExtensionValidate.isValidExtensionKey;
import static com.google.common.collect.Sets.newHashSet;
/**
*
*/
@Component
public class DefaultGitRepositoryManager implements DisposableBean, GitRepositoryManager
{
private static final String BUNDLELASTMODIFIED = "bundlelastmodified";
private final File repositoriesDir;
private final Map<String,Repository> repositories = new MapMaker().makeMap();
private final BundleContext bundleContext;
private final EventPublisher eventPublisher;
private final KeyedSyncExecutor<Repository,AbstractExtensionEvent<?>> executor;
private static final Logger log = LoggerFactory.getLogger(DefaultGitRepositoryManager.class);
private static final AbstractExtensionEvent SYNC_EVENT = new AbstractExtensionEvent("")
{
@Override
public String getMessage()
{
return "Auto-sync from deployed extension";
}
@Override
public String getUserEmail()
{
return "speakeasy@atlassian.com";
}
@Override
public String getUserName()
{
return "speakeasy";
}
};
@Autowired
public DefaultGitRepositoryManager(BundleContext bundleContext, ApplicationProperties applicationProperties, EventPublisher eventPublisher)
{
this.bundleContext = bundleContext;
this.eventPublisher = eventPublisher;
repositoriesDir = new File(applicationProperties.getHomeDirectory(), "data/speakeasy/repositories");
repositoriesDir.mkdirs();
executor = new KeyedSyncExecutor<Repository, AbstractExtensionEvent<?>>()
{
@Override
protected Repository getTarget(String id, AbstractExtensionEvent<?> targetContext) throws Exception
{
return getRepository(id, targetContext);
}
@Override
protected boolean allowKey(String id)
{
return isValidExtensionKey(id);
}
};
eventPublisher.register(this);
}
private Repository getRepository(String id, AbstractExtensionEvent event) throws NoHeadException, NoMessageException, ConcurrentRefUpdateException, WrongRepositoryStateException, IOException, NoFilepatternException
{
Repository repo = repositories.get(id);
if (repo == null)
{
final File repoDir = new File(repositoriesDir, id);
FileRepositoryBuilder builder = new FileRepositoryBuilder();
repo = builder.setWorkTree(repoDir).
setGitDir(new File(repoDir, ".git")).
setMustExist(false).
build();
Bundle bundle = findBundleForPlugin(bundleContext, id);
if (bundle != null)
{
if (!repo.getObjectDatabase().exists())
{
repo.create();
updateRepositoryIfDirty(repo, event, bundle);
}
else
{
long modified = repo.getConfig().getLong("speakeasy", null, BUNDLELASTMODIFIED, 0);
if (modified != bundle.getLastModified())
{
updateRepositoryIfDirty(repo, event, bundle);
}
}
}
else if (ExtensionValidate.isValidExtensionKey(id) &&
!repo.getDirectory().exists())
{
repo.create();
}
repositories.put(id, repo);
}
return repo;
}
public File getRepositoriesDir()
{
return this.repositoriesDir;
}
private void updateRepositoryIfDirty(Repository repo, AbstractExtensionEvent event, Bundle bundle) throws IOException, NoFilepatternException, NoHeadException, NoMessageException, ConcurrentRefUpdateException, WrongRepositoryStateException
{
final File workTree = repo.getWorkTree();
Git git = new Git(repo);
Set<File> workTreeFilesToDelete = findFiles(repo.getWorkTree());
for (String path : getPublicBundlePathsRecursive(bundle, ""))
{
File target = new File(workTree, path);
workTreeFilesToDelete.remove(target);
if (path.endsWith("/"))
{
target.mkdirs();
}
else
{
FileOutputStream fout = null;
try
{
fout = new FileOutputStream(target);
IOUtils.copy(bundle.getResource(path).openStream(), fout);
fout.close();
}
finally
{
IOUtils.closeQuietly(fout);
}
git.add().addFilepattern(path).call();
}
}
for (File file : workTreeFilesToDelete)
{
FileUtils.deleteQuietly(file);
}
Status status = git.status().call();
if (!status.getAdded().isEmpty() ||
!status.getChanged().isEmpty() ||
!status.getMissing().isEmpty() ||
!status.getRemoved().isEmpty() ||
!status.getUntracked().isEmpty())
{
git.commit().
setAll(true).
setAuthor(event.getUserName(), event.getUserEmail()).
setCommitter("speakeasy", "speakeasy@atlassian.com").
setMessage(event.getMessage()).
call();
log.info("Git repository {} updated", repo.getWorkTree().getName());
}
updateWithBundleTimestamp(repo, bundle);
}
private void updateWithBundleTimestamp(Repository repo, Bundle bundle) throws IOException
{
StoredConfig config = repo.getConfig();
config.setLong("speakeasy", null, BUNDLELASTMODIFIED, bundle.getLastModified());
config.save();
}
private Set<File> findFiles(File workTree)
{
Set<File> paths = newHashSet();
for (File child : workTree.listFiles())
{
if (!".git".equals(child.getName()))
{
paths.add(child);
if (child.isDirectory())
{
paths.addAll(findFiles(child));
}
}
}
return paths;
}
public void ensureRepository(String name)
{
executor.forKey(name, SYNC_EVENT, new Operation<Repository, Void>()
{
public Void operateOn(Repository repo) throws Exception
{
return null;
}
});
}
public <R> R operateOnRepository(String name, Operation<Repository, R> operation)
{
return executor.forKey(name, SYNC_EVENT, operation);
}
public File buildJarFromRepository(String pluginKey)
{
File jar = executor.forKey(pluginKey, SYNC_EVENT, new Operation<Repository, File>()
{
public File operateOn(Repository repo) throws Exception
{
Git git = new Git(repo);
if (repo.getAllRefs().containsKey("refs/heads/master"))
{
git.reset().setMode(ResetCommand.ResetType.HARD).setRef("HEAD").call();
for (String path : git.status().call().getUntracked())
{
new File(repo.getWorkTree(), path).delete();
}
}
return ZipWriter.addDirectoryContentsToJar(repo.getWorkTree(), ".git");
}
});
if (jar == null)
{
throw new PluginOperationFailedException("Invalid plugin key: " + pluginKey, pluginKey);
}
return jar;
}
@EventListener
public void onPluginInstalledEvent(final ExtensionInstalledEvent event)
{
executor.forKey(event.getPluginKey(), event, new Operation<Repository, Void>()
{
public Void operateOn(Repository repo) throws Exception
{
// just getting the repo for the first time will create it
updateRepositoryIfDirty(repo, event, BundleUtil.findBundleForPlugin(bundleContext, repo.getWorkTree().getName()));
return null;
}
});
}
@EventListener
public void onPluginUninstalledEvent(final ExtensionUninstalledEvent event)
{
executor.forKey(event.getPluginKey(), event, new Operation<Repository, Void>()
{
public Void operateOn(Repository repo) throws Exception
{
removeRepository(repo);
return null;
}
});
}
@EventListener
public void onPluginUpdatedEvent(final ExtensionUpdatedEvent event)
{
executor.forKey(event.getPluginKey(), event, new Operation<Repository, Void>()
{
public Void operateOn(Repository repo) throws Exception
{
updateRepositoryIfDirty(repo, event, BundleUtil.findBundleForPlugin(bundleContext, event.getPluginKey()));
return null;
}
});
}
@EventListener
public void onPluginForkedEvent(final ExtensionForkedEvent event)
{
executor.forKey(event.getPluginKey(), event, new Operation<Repository, Void>()
{
public Void operateOn(Repository repo) throws Exception
{
cloneRepository(repo, event.getForkedPluginKey());
return null;
}
});
}
private void cloneRepository(Repository repo, String forkedPluginKey)
{
Git git = new Git(repo);
git.cloneRepository()
.setURI(repo.getDirectory().toURI().toString())
.setDirectory(new File(repositoriesDir, forkedPluginKey))
.setBare(false)
.call();
executor.forKey(forkedPluginKey, SYNC_EVENT, new Operation<Repository, Void>()
{
public Void operateOn(Repository repo) throws Exception
{
// do nothing, we just want to force a sync
return null;
}
});
log.info("Git repository {} cloned to {}", repo.getWorkTree().getName(), forkedPluginKey);
}
private void removeRepository(Repository repo) throws IOException
{
File workTreeDir = repo.getWorkTree();
repo.close();
FileUtils.deleteDirectory(workTreeDir);
final String pluginKey = workTreeDir.getName();
repositories.remove(pluginKey);
log.info("Git repository {} removed", pluginKey);
}
public void destroy() throws Exception
{
eventPublisher.unregister(this);
}
}