/*
* 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.
*
* Contributions from 2013-2017 where performed either by US government
* employees, or under US Veterans Health Administration contracts.
*
* US Veterans Health Administration contributions by government employees
* are work of the U.S. Government and are not subject to copyright
* protection in the United States. Portions contributed by government
* employees are USGovWork (17USC ยง105). Not subject to copyright.
*
* Contribution by contractors to the US Veterans Health Administration
* during this period are contractually contributed under the
* Apache License, Version 2.0.
*
* See: https://www.usa.gov/government-works
*
* Contributions prior to 2013:
*
* Copyright (C) International Health Terminology Standards Development Organisation.
* Licensed under the Apache License, Version 2.0.
*
*/
package sh.isaac.provider.sync.git;
//~--- JDK imports ------------------------------------------------------------
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import javax.naming.AuthenticationException;
//~--- non-JDK imports --------------------------------------------------------
import org.eclipse.jgit.api.AddCommand;
import org.eclipse.jgit.api.CheckoutCommand.Stage;
import org.eclipse.jgit.api.CommitCommand;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.MergeResult;
import org.eclipse.jgit.api.ResetCommand.ResetType;
import org.eclipse.jgit.api.RmCommand;
import org.eclipse.jgit.api.Status;
import org.eclipse.jgit.api.errors.CheckoutConflictException;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.StashApplyFailureException;
import org.eclipse.jgit.api.errors.TransportException;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.LargeObjectException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.NoWorkTreeException;
import org.eclipse.jgit.internal.storage.file.FileRepository;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.notes.Note;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.FetchResult;
import org.eclipse.jgit.transport.JschConfigSessionFactory;
import org.eclipse.jgit.transport.OpenSshConfig.Host;
import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.SshSessionFactory;
import org.eclipse.jgit.transport.TagOpt;
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
import org.eclipse.jgit.util.StringUtils;
import org.eclipse.jgit.util.io.DisabledOutputStream;
import org.glassfish.hk2.api.PerLookup;
import org.jvnet.hk2.annotations.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import sh.isaac.api.sync.MergeFailOption;
import sh.isaac.api.sync.MergeFailure;
import sh.isaac.api.sync.SyncFiles;
//~--- classes ----------------------------------------------------------------
/**
* {@link SyncServiceGIT}
*
* A GIT implementation of {@link SyncFiles}.
*
* @author <a href="mailto:daniel.armbrust.list@gmail.com">Dan Armbrust</a>
*/
@Service(name = "GIT")
@PerLookup
public class SyncServiceGIT
implements SyncFiles {
/** The log. */
private static Logger log = LoggerFactory.getLogger(SyncServiceGIT.class);
/** The jsch configured. */
private static volatile CountDownLatch jschConfigured = new CountDownLatch(1);
//~--- fields --------------------------------------------------------------
/** The note failed merge happened on remote. */
private final String NOTE_FAILED_MERGE_HAPPENED_ON_REMOTE = "Conflicted merge happened during remote merge";
/** The note failed merge happened on stash. */
private final String NOTE_FAILED_MERGE_HAPPENED_ON_STASH = "Conflicted merge happened during stash merge";
/** The stash marker. */
private final String STASH_MARKER = ":STASH-";
/** The local folder. */
private File localFolder = null;
/** The read me file content. */
private String readMeFileContent = DEFAULT_README_CONTENT;
/** The git ignore text. */
private String gitIgnoreText = "lastUser.txt\r\n";
//~--- constructors --------------------------------------------------------
/**
* If you are in an HK2 environment, you would be better served getting this from HK2 (by asking for it by interface and name)
* but in other enviornments, when HK2 may not be up, you may construct it directly.
*/
public SyncServiceGIT() {
synchronized (jschConfigured) {
if (jschConfigured.getCount() > 0) {
log.debug("Disabling strict host key checking");
final SshSessionFactory factory = new JschConfigSessionFactory() {
@Override
protected void configure(Host hc, Session session) {
session.setConfig("StrictHostKeyChecking", "no");
}
};
SshSessionFactory.setInstance(factory);
JSch.setLogger(new com.jcraft.jsch.Logger() {
private final HashMap<Integer, Consumer<String>> logMap = new HashMap<>();
private final HashMap<Integer, BooleanSupplier> enabledMap = new HashMap<>();
{
// Note- JSCH is _really_ verbose at the INFO level, so I'm mapping info to DEBUG.
this.logMap.put(com.jcraft.jsch.Logger.DEBUG, log::debug);
this.logMap.put(com.jcraft.jsch.Logger.ERROR, log::debug); // error
this.logMap.put(com.jcraft.jsch.Logger.FATAL, log::debug); // error
this.logMap.put(com.jcraft.jsch.Logger.INFO, log::debug);
this.logMap.put(com.jcraft.jsch.Logger.WARN, log::debug); // warn
this.enabledMap.put(com.jcraft.jsch.Logger.DEBUG, log::isDebugEnabled);
this.enabledMap.put(com.jcraft.jsch.Logger.ERROR, log::isErrorEnabled);
this.enabledMap.put(com.jcraft.jsch.Logger.FATAL, log::isErrorEnabled);
this.enabledMap.put(com.jcraft.jsch.Logger.INFO, log::isDebugEnabled);
this.enabledMap.put(com.jcraft.jsch.Logger.WARN, log::isWarnEnabled);
}
@Override
public void log(int level, String message) {
this.logMap.get(level)
.accept(message);
}
@Override
public boolean isEnabled(int level) {
return this.enabledMap.get(level)
.getAsBoolean();
}
});
jschConfigured.countDown();
}
}
}
//~--- methods -------------------------------------------------------------
/**
* Adds the files.
*
* @param files the files
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @see sh.isaac.api.sync.SyncFiles#addFiles(java.io.File, java.util.Set)
*/
@Override
public void addFiles(String... files)
throws IllegalArgumentException, IOException {
log.info("Add Files called {}", Arrays.toString(files));
try (Git git = getGit()) {
if (files.length == 0) {
log.debug("No files to add");
} else {
final AddCommand ac = git.add();
for (final String file: files) {
ac.addFilepattern(file);
}
ac.call();
}
log.info("addFiles Complete. Current status: " + statusToString(git.status().call()));
} catch (final GitAPIException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Adds the untracked files.
*
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @see sh.isaac.api.sync.SyncFiles#addUntrackedFiles(java.io.File)
*/
@Override
public void addUntrackedFiles()
throws IllegalArgumentException, IOException {
log.info("Add Untracked files called");
try (Git git = getGit()) {
final Status s = git.status()
.call();
addFiles(s.getUntracked()
.toArray(new String[s.getUntracked().size()]));
} catch (final GitAPIException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Create a new branch, and switch to it locally. The new branch will contain no files.
*
* @param branchName the branch name
* @throws IOException Signals that an I/O exception has occurred.
*/
public void branch(String branchName)
throws IOException {
try (Git git = getGit()) {
git.checkout()
.setCreateBranch(true)
.setName(branchName)
.setOrphan(true)
.call();
} catch (final GitAPIException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Create a new tag at the current point.
*
* @param commitMessage the commit message
* @param tagName the tag name
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
*/
public void commitAndTag(String commitMessage, String tagName)
throws IllegalArgumentException, IOException {
try (Git git = getGit()) {
git.commit()
.setAll(true)
.setMessage(commitMessage)
.call();
git.tag()
.setName(tagName)
.call();
} catch (final GitAPIException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Link and fetch from remote.
*
* @param remoteAddress the remote address
* @param username the username
* @param password the password
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @throws AuthenticationException the authentication exception
* @see sh.isaac.api.sync.SyncFiles#linkAndFetchFromRemote(java.io.File, java.lang.String, java.lang.String, java.lang.String)
*/
@Override
public void linkAndFetchFromRemote(String remoteAddress,
String username,
char[] password)
throws IllegalArgumentException,
IOException,
AuthenticationException {
log.info("linkAndFetchFromRemote called - folder: {}, remoteAddress: {}, username: {}",
this.localFolder,
remoteAddress,
username);
Repository r = null;
Git git = null;
try {
final File gitFolder = new File(this.localFolder, ".git");
r = new FileRepository(gitFolder);
if (!gitFolder.isDirectory()) {
log.debug("Root folder does not contain a .git subfolder. Creating new git repository.");
r.create();
}
relinkRemote(remoteAddress, username, password);
git = new Git(r);
final CredentialsProvider cp = new UsernamePasswordCredentialsProvider(username,
((password == null) ? new char[] {}
: password));
log.debug("Fetching");
final FetchResult fr = git.fetch()
.setCheckFetchedObjects(true)
.setCredentialsProvider(cp)
.call();
log.debug("Fetch messages: {}", fr.getMessages());
boolean remoteHasMaster = false;
final Collection<Ref> refs = git.lsRemote()
.setCredentialsProvider(cp)
.call();
for (final Ref ref: refs) {
if ("refs/heads/master".equals(ref.getName())) {
remoteHasMaster = true;
log.debug("Remote already has 'heads/master'");
break;
}
}
if (remoteHasMaster) {
// we need to fetch and (maybe) merge - get onto origin/master.
log.debug("Fetching from remote");
final String fetchResult = git.fetch()
.setCredentialsProvider(cp)
.call()
.getMessages();
log.debug("Fetch Result: {}", fetchResult);
log.debug("Resetting to origin/master");
git.reset()
.setMode(ResetType.MIXED)
.setRef("origin/master")
.call();
// Get the files from master that we didn't have in our working folder
log.debug("Checking out missing files from origin/master");
for (final String missing: git.status()
.call()
.getMissing()) {
log.debug("Checkout {}", missing);
git.checkout()
.addPath(missing)
.call();
}
for (final String newFile: makeInitialFilesAsNecessary(this.localFolder)) {
log.debug("Adding and committing {}", newFile);
git.add()
.addFilepattern(newFile)
.call();
git.commit()
.setMessage("Adding " + newFile)
.setAuthor(username, "42")
.call();
for (final PushResult pr: git.push()
.setCredentialsProvider(cp)
.call()) {
log.debug("Push Message: {}", pr.getMessages());
}
}
} else {
// just push
// make sure we have something to push
for (final String newFile: makeInitialFilesAsNecessary(this.localFolder)) {
log.debug("Adding and committing {}", newFile);
git.add()
.addFilepattern(newFile)
.call();
}
git.commit()
.setMessage("Adding initial files")
.setAuthor(username, "42")
.call();
log.debug("Pushing repository");
for (final PushResult pr: git.push()
.setCredentialsProvider(cp)
.call()) {
log.debug("Push Result: {}", pr.getMessages());
}
}
log.info("linkAndFetchFromRemote Complete. Current status: " + statusToString(git.status().call()));
} catch (final TransportException te) {
if (te.getMessage().contains("Auth fail") || te.getMessage().contains("not authorized")) {
log.info("Auth fail", te);
throw new AuthenticationException("Auth fail");
} else {
log.error("Unexpected", te);
throw new IOException("Internal error", te);
}
} catch (final GitAPIException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
} finally {
if (git != null) {
git.close();
}
if (r != null) {
r.close();
}
}
}
/**
* Push tag.
*
* @param tagName the tag name
* @param username the username
* @param password the password
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @throws AuthenticationException the authentication exception
*/
public void pushTag(final String tagName,
String username,
char[] password)
throws IllegalArgumentException,
IOException,
AuthenticationException {
try (Git git = getGit()) {
final CredentialsProvider cp = new UsernamePasswordCredentialsProvider(username,
((password == null) ? new char[] {}
: password));
final Iterable<PushResult> pr = git.push()
.setRefSpecs(new RefSpec("refs/tags/" + tagName))
.setCredentialsProvider(cp)
.call();
final StringBuilder failures = new StringBuilder();
pr.forEach(t -> {
log.debug("Push Result Messages: " + t.getMessages());
if (t.getRemoteUpdate("refs/tags/" + tagName)
.getStatus() != org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK) {
failures.append("Push Failed: " +
t.getRemoteUpdate("refs/tags/" + tagName).getStatus().name() + " reason: " +
t.getRemoteUpdate("refs/tags/" + tagName).getMessage());
}
});
if (failures.length() > 0) {
throw new IOException(failures.toString());
}
} catch (final GitAPIException e) {
if (e.getMessage().contains("Auth fail") || e.getMessage().contains("not authorized")) {
log.info("Auth fail", e);
throw new AuthenticationException("Auth fail");
} else {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
}
/**
* Read tags.
*
* @param username the username
* @param password the password
* @return the array list
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @throws AuthenticationException the authentication exception
*/
public ArrayList<String> readTags(String username,
char[] password)
throws IllegalArgumentException,
IOException,
AuthenticationException {
try (Git git = getGit()) {
final ArrayList<String> results = new ArrayList<>();
final CredentialsProvider cp = new UsernamePasswordCredentialsProvider(username,
((password == null) ? new char[] {}
: password));
git.fetch()
.setTagOpt(TagOpt.FETCH_TAGS)
.setCredentialsProvider(cp)
.call();
for (final Ref x: git.tagList()
.call()) {
results.add(x.getName());
}
git.close();
return results;
} catch (final GitAPIException e) {
if (e.getMessage().contains("Auth fail") || e.getMessage().contains("not authorized")) {
log.info("Auth fail", e);
throw new AuthenticationException("Auth fail");
} else {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
}
/**
* Relink remote.
*
* @param remoteAddress the remote address
* @param username the username
* @param password the password
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @see sh.isaac.api.sync.SyncFiles#relinkRemote(java.lang.String, java.lang.String, java.lang.String)
*/
@Override
public void relinkRemote(String remoteAddress,
String username,
char[] password)
throws IllegalArgumentException,
IOException {
try (Git git = getGit()) {
log.debug("Configuring remote URL and fetch defaults to {}", remoteAddress);
final StoredConfig sc = git.getRepository()
.getConfig();
sc.setString("remote", "origin", "url", remoteAddress);
sc.setString("remote", "origin", "fetch", "+refs/heads/*:refs/remotes/origin/*");
sc.save();
}
}
/**
* Removes the files.
*
* @param files the files
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @see sh.isaac.api.sync.SyncFiles#removeFiles(java.io.File, java.util.Set)
*/
@Override
public void removeFiles(String... files)
throws IllegalArgumentException, IOException {
log.info("Remove Files called {}", Arrays.toString(files));
try (Git git = getGit()) {
if (files.length == 0) {
log.debug("No files to remove");
} else {
final RmCommand rm = git.rm();
for (final String file: files) {
rm.addFilepattern(file);
}
rm.call();
}
log.info("removeFiles Complete. Current status: " + statusToString(git.status().call()));
} catch (final GitAPIException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Resolve merge failures.
*
* @param resolutions the resolutions
* @return the set
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @throws NoWorkTreeException the no work tree exception
* @throws MergeFailure the merge failure
* @see sh.isaac.api.sync.SyncFiles#resolveMergeFailures(java.io.File, java.util.Map)
*/
@Override
public Set<String> resolveMergeFailures(Map<String, MergeFailOption> resolutions)
throws IllegalArgumentException,
IOException,
NoWorkTreeException,
MergeFailure {
log.info("resolve merge failures called - resolutions: {}", resolutions);
try (Git git = getGit()) {
final List<Note> notes = git.notesList()
.call();
final Set<String> conflicting = git.status()
.call()
.getConflicting();
if (conflicting.size() == 0) {
throw new IllegalArgumentException("You do not appear to have any conflicting files");
}
if (conflicting.size() != resolutions.size()) {
throw new IllegalArgumentException(
"You must provide a resolution for each conflicting file. Files in conflict: " + conflicting);
}
for (final String s: conflicting) {
if (!resolutions.containsKey(s)) {
throw new IllegalArgumentException("No conflit resolution specified for file " + s +
". Resolutions must be specified for all files");
}
}
if ((notes == null) || (notes.size() == 0)) {
throw new IllegalArgumentException(
"The 'note' that is required for tracking state is missing. This merge failure must be resolved on the command line");
}
final String noteValue = new String(git.getRepository().open(notes.get(0).getData()).getBytes());
MergeFailType mergeFailType;
if (noteValue.startsWith(this.NOTE_FAILED_MERGE_HAPPENED_ON_REMOTE)) {
mergeFailType = MergeFailType.REMOTE_TO_LOCAL;
} else if (noteValue.startsWith(this.NOTE_FAILED_MERGE_HAPPENED_ON_STASH)) {
mergeFailType = MergeFailType.STASH_TO_LOCAL;
} else {
throw new IllegalArgumentException(
"The 'note' that is required for tracking state contains an unexpected value of '" + noteValue + "'");
}
String stashIdToApply = null;
if (noteValue.contains(this.STASH_MARKER)) {
stashIdToApply = noteValue.substring(noteValue.indexOf(this.STASH_MARKER) + this.STASH_MARKER.length());
}
return resolveMergeFailures(mergeFailType, stashIdToApply, resolutions);
} catch (GitAPIException | LargeObjectException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Substitute URL.
*
* @param url the url
* @param username the username
* @return the string
* @see sh.isaac.api.sync.SyncFiles#substituteURL(java.lang.String, java.lang.String)
*
* Turns
* ssh://someuser@csfe.aceworkspace.net:29418/... into
* ssh://username.toString()@csfe.aceworkspace.net:29418/...
*
* Otherwise, returns URL.
*/
@Override
public String substituteURL(String url, String username) {
if (url.startsWith("ssh://") && url.contains("@")) {
final int index = url.indexOf("@");
url = "ssh://" + username + url.substring(index);
}
return url;
}
/**
* Update commit and push.
*
* @param commitMessage the commit message
* @param username the username
* @param password the password
* @param mergeFailOption the merge fail option
* @param files the files
* @return the set
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @throws MergeFailure the merge failure
* @throws AuthenticationException the authentication exception
* @see sh.isaac.api.sync.SyncFiles#updateCommitAndPush(java.io.File, java.lang.String, java.lang.String, java.lang.String,
* java.lang.String[])
*/
@Override
public Set<String> updateCommitAndPush(String commitMessage,
String username,
char[] password,
MergeFailOption mergeFailOption,
String... files)
throws IllegalArgumentException,
IOException,
MergeFailure,
AuthenticationException {
log.info("Commit Files called {}", ((files == null) ? "-null-"
: Arrays.toString(files)));
try (Git git = getGit()) {
if (git.status()
.call()
.getConflicting()
.size() > 0) {
log.info("Previous merge failure not yet resolved");
throw new MergeFailure(git.status().call().getConflicting(), new HashSet<>());
}
if (files == null) {
files = git.status()
.call()
.getUncommittedChanges()
.toArray(new String[0]);
log.info("Will commit the uncommitted files {}", Arrays.toString(files));
}
if (StringUtils.isEmptyOrNull(commitMessage) && (files.length > 0)) {
throw new IllegalArgumentException("The commit message is required when files are specified");
}
if (files.length > 0) {
final CommitCommand commit = git.commit();
for (final String file: files) {
commit.setOnly(file);
}
commit.setAuthor(username, "42");
commit.setMessage(commitMessage);
final RevCommit rv = commit.call();
log.debug("Local commit completed: " + rv.getFullMessage());
}
// need to merge origin/master into master now, prior to push
final Set<String> result = updateFromRemote(username, password, mergeFailOption);
log.debug("Pushing");
final CredentialsProvider cp = new UsernamePasswordCredentialsProvider(username,
((password == null) ? new char[] {}
: password));
final Iterable<PushResult> pr = git.push()
.setCredentialsProvider(cp)
.call();
pr.forEach(t -> log.debug("Push Result Messages: " + t.getMessages()));
log.info("commit and push complete. Current status: " + statusToString(git.status().call()));
return result;
} catch (final TransportException te) {
if (te.getMessage().contains("Auth fail") || te.getMessage().contains("not authorized")) {
log.info("Auth fail", te);
throw new AuthenticationException("Auth fail");
} else {
log.error("Unexpected", te);
throw new IOException("Internal error", te);
}
} catch (final GitAPIException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Update from remote.
*
* @param username the username
* @param password the password
* @param mergeFailOption the merge fail option
* @return the set
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @throws MergeFailure the merge failure
* @throws AuthenticationException the authentication exception
* @see sh.isaac.api.sync.SyncFiles#updateFromRemote(java.io.File, java.lang.String, java.lang.String,
* sh.isaac.api.sync.MergeFailOption)
*/
@Override
public Set<String> updateFromRemote(String username,
char[] password,
MergeFailOption mergeFailOption)
throws IllegalArgumentException,
IOException,
MergeFailure,
AuthenticationException {
log.info("update from remote called ");
Set<String> filesChangedDuringPull;
try (Git git = getGit()) {
log.debug("Fetching from remote");
if (git.status()
.call()
.getConflicting()
.size() > 0) {
log.info("Previous merge failure not yet resolved");
throw new MergeFailure(git.status().call().getConflicting(), new HashSet<>());
}
final CredentialsProvider cp = new UsernamePasswordCredentialsProvider(username,
((password == null) ? new char[] {}
: password));
log.debug("Fetch Message" + git.fetch().setCredentialsProvider(cp).call().getMessages());
final ObjectId masterIdBeforeMerge = git.getRepository()
.findRef("master")
.getObjectId();
if (git.getRepository()
.exactRef("refs/remotes/origin/master")
.getObjectId()
.getName()
.equals(masterIdBeforeMerge.getName())) {
log.info("No changes to merge");
return new HashSet<>();
}
RevCommit stash = null;
if (git.status()
.call()
.getUncommittedChanges()
.size() > 0) {
log.info("Stashing uncommitted changes");
stash = git.stashCreate()
.call();
}
{
log.debug("Merging from remotes/origin/master");
final MergeResult mr = git.merge()
.include(git.getRepository()
.exactRef("refs/remotes/origin/master"))
.call();
final AnyObjectId headAfterMergeID = mr.getNewHead();
if (!mr.getMergeStatus()
.isSuccessful()) {
if ((mergeFailOption == null) || (MergeFailOption.FAIL == mergeFailOption)) {
addNote(this.NOTE_FAILED_MERGE_HAPPENED_ON_REMOTE + ((stash == null) ? ":NO_STASH"
: this.STASH_MARKER + stash.getName()), git);
// We can use the status here - because we already stashed the stuff that they had uncommitted above.
throw new MergeFailure(mr.getConflicts().keySet(), git.status().call().getUncommittedChanges());
} else if ((MergeFailOption.KEEP_LOCAL == mergeFailOption) ||
(MergeFailOption.KEEP_REMOTE == mergeFailOption)) {
final HashMap<String, MergeFailOption> resolutions = new HashMap<>();
for (final String s: mr.getConflicts()
.keySet()) {
resolutions.put(s, mergeFailOption);
}
log.debug("Resolving merge failures with option {}", mergeFailOption);
filesChangedDuringPull = resolveMergeFailures(MergeFailType.REMOTE_TO_LOCAL, ((stash == null) ? null
: stash.getName()), resolutions);
} else {
throw new IllegalArgumentException("Unexpected option");
}
} else {
// Conflict free merge - or perhaps, no merge at all.
if (masterIdBeforeMerge.getName()
.equals(headAfterMergeID.getName())) {
log.debug("Merge didn't result in a commit - no incoming changes");
filesChangedDuringPull = new HashSet<>();
} else {
filesChangedDuringPull = listFilesChangedInCommit(git.getRepository(),
masterIdBeforeMerge,
headAfterMergeID);
}
}
}
if (stash != null) {
log.info("Replaying stash");
try {
git.stashApply()
.setStashRef(stash.getName())
.call();
log.debug("stash applied cleanly, dropping stash");
git.stashDrop()
.call();
} catch (final StashApplyFailureException e) {
log.debug("Stash failed to merge");
if ((mergeFailOption == null) || (MergeFailOption.FAIL == mergeFailOption)) {
addNote(this.NOTE_FAILED_MERGE_HAPPENED_ON_STASH, git);
throw new MergeFailure(git.status().call().getConflicting(), filesChangedDuringPull);
} else if ((MergeFailOption.KEEP_LOCAL == mergeFailOption) ||
(MergeFailOption.KEEP_REMOTE == mergeFailOption)) {
final HashMap<String, MergeFailOption> resolutions = new HashMap<>();
for (final String s: git.status()
.call()
.getConflicting()) {
resolutions.put(s, mergeFailOption);
}
log.debug("Resolving stash apply merge failures with option {}", mergeFailOption);
resolveMergeFailures(MergeFailType.STASH_TO_LOCAL, null, resolutions);
// When we auto resolve to KEEP_LOCAL - these files won't have really changed, even though we recorded a change above.
for (final Entry<String, MergeFailOption> r: resolutions.entrySet()) {
if (MergeFailOption.KEEP_LOCAL == r.getValue()) {
filesChangedDuringPull.remove(r.getKey());
}
}
} else {
throw new IllegalArgumentException("Unexpected option");
}
}
}
log.info("Files changed during updateFromRemote: {}", filesChangedDuringPull);
return filesChangedDuringPull;
} catch (final CheckoutConflictException e) {
log.error("Unexpected", e);
throw new IOException(
"A local file exists (but is not yet added to source control) which conflicts with a file from the server." +
" Either delete the local file, or call addFile(...) on the offending file prior to attempting to update from remote.",
e);
} catch (final TransportException te) {
if (te.getMessage().contains("Auth fail") || te.getMessage().contains("not authorized")) {
log.info("Auth fail", te);
throw new AuthenticationException("Auth fail");
} else {
log.error("Unexpected", te);
throw new IOException("Internal error", te);
}
} catch (final GitAPIException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Adds the note.
*
* @param message the message
* @param git the git
* @throws IOException Signals that an I/O exception has occurred.
* @throws GitAPIException the git API exception
*/
private void addNote(String message, Git git)
throws IOException, GitAPIException {
final RevWalk walk = new RevWalk(git.getRepository());
final Ref head = git.getRepository()
.exactRef("refs/heads/master");
final RevCommit commit = walk.parseCommit(head.getObjectId());
git.notesAdd()
.setObjectId(commit)
.setMessage(message)
.call();
walk.close();
}
/**
* List files changed in commit.
*
* @param repository the repository
* @param beforeID the before ID
* @param afterID the after ID
* @return the hash set
* @throws MissingObjectException the missing object exception
* @throws IncorrectObjectTypeException the incorrect object type exception
* @throws IOException Signals that an I/O exception has occurred.
*/
private HashSet<String> listFilesChangedInCommit(Repository repository,
AnyObjectId beforeID,
AnyObjectId afterID)
throws MissingObjectException,
IncorrectObjectTypeException,
IOException {
log.info("calculating files changed in commit");
final HashSet<String> result = new HashSet<>();
final RevWalk rw = new RevWalk(repository);
final RevCommit commitBefore = rw.parseCommit(beforeID);
final RevCommit commitAfter = rw.parseCommit(afterID);
rw.close();
final DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE);
df.setRepository(repository);
df.setDiffComparator(RawTextComparator.DEFAULT);
df.setDetectRenames(true);
final List<DiffEntry> diffs = df.scan(commitBefore.getTree(), commitAfter.getTree());
for (final DiffEntry diff: diffs) {
result.add(diff.getNewPath());
}
df.close();
log.debug("Files changed between commits commit: {} and {} - {}", beforeID.getName(), afterID, result);
return result;
}
/**
* returns a list of newly created files and files that were modified.
*
* @param containingFolder the containing folder
* @return the list
* @throws IOException Signals that an I/O exception has occurred.
*/
private List<String> makeInitialFilesAsNecessary(File containingFolder)
throws IOException {
final ArrayList<String> result = new ArrayList<>();
final File readme = new File(containingFolder, "README.md");
if (!readme.isFile()) {
log.debug("Creating {}", readme.getAbsolutePath());
Files.write(readme.toPath(), new String(this.readMeFileContent).getBytes(), StandardOpenOption.CREATE_NEW);
result.add(readme.getName());
} else {
log.debug("README.md already exists");
}
final File ignore = new File(containingFolder, ".gitignore");
if (!ignore.isFile()) {
log.debug("Creating {}", ignore.getAbsolutePath());
Files.write(ignore.toPath(), new String(this.gitIgnoreText).getBytes(), StandardOpenOption.CREATE_NEW);
result.add(ignore.getName());
} else {
log.debug(".gitignore already exists");
if (!new String(Files.readAllBytes(ignore.toPath())).contains(this.gitIgnoreText)) {
log.debug("Appending onto existing .gitignore file");
Files.write(ignore.toPath(),
new String("\r\n" + this.gitIgnoreText).getBytes(),
StandardOpenOption.APPEND);
result.add(ignore.getName());
}
}
return result;
}
/**
* Resolve merge failures.
*
* @param mergeFailType the merge fail type
* @param stashIDToApply the stash ID to apply
* @param resolutions the resolutions
* @return the set
* @throws IllegalArgumentException the illegal argument exception
* @throws IOException Signals that an I/O exception has occurred.
* @throws MergeFailure the merge failure
*/
private Set<String> resolveMergeFailures(MergeFailType mergeFailType,
String stashIDToApply,
Map<String, MergeFailOption> resolutions)
throws IllegalArgumentException,
IOException,
MergeFailure {
log.debug("resolve merge failures called - mergeFailType: {} stashIDToApply: {} resolutions: {}",
mergeFailType,
stashIDToApply,
resolutions);
try (Git git = getGit();) {
// We unfortunately, must know the mergeFailType option, because the resolution mechanism here uses OURS and THEIRS - but the
// meaning of OURS and THEIRS reverse, depending on if you are recovering from a merge failure, or a stash apply failure.
for (final Entry<String, MergeFailOption> r: resolutions.entrySet()) {
if (MergeFailOption.FAIL == r.getValue()) {
throw new IllegalArgumentException("MergeFailOption.FAIL is not a valid option");
} else if (MergeFailOption.KEEP_LOCAL == r.getValue()) {
log.debug("Keeping our local file for conflict {}", r.getKey());
git.checkout()
.addPath(r.getKey())
.setStage((MergeFailType.REMOTE_TO_LOCAL == mergeFailType) ? Stage.OURS
: Stage.THEIRS)
.call();
} else if (MergeFailOption.KEEP_REMOTE == r.getValue()) {
log.debug("Keeping remote file for conflict {}", r.getKey());
git.checkout()
.addPath(r.getKey())
.setStage((MergeFailType.REMOTE_TO_LOCAL == mergeFailType) ? Stage.THEIRS
: Stage.OURS)
.call();
} else {
throw new IllegalArgumentException("MergeFailOption is required");
}
log.debug("calling add to mark merge resolved");
git.add()
.addFilepattern(r.getKey())
.call();
}
if (mergeFailType == MergeFailType.STASH_TO_LOCAL) {
// clean up the stash
log.debug("Dropping stash");
git.stashDrop()
.call();
}
final RevWalk walk = new RevWalk(git.getRepository());
final Ref head = git.getRepository()
.exactRef("refs/heads/master");
final RevCommit commitWithPotentialNote = walk.parseCommit(head.getObjectId());
walk.close();
log.info("resolve merge failures Complete. Current status: " + statusToString(git.status().call()));
final RevCommit rc = git.commit()
.setMessage("Merging with user specified merge failure resolution for files " +
resolutions.keySet())
.call();
git.notesRemove()
.setObjectId(commitWithPotentialNote)
.call();
final Set<String> filesChangedInCommit = listFilesChangedInCommit(git.getRepository(),
commitWithPotentialNote.getId(),
rc);
// When we auto resolve to KEEP_REMOTE - these will have changed - make sure they are in the list.
// seems like this shouldn't really be necessary - need to look into the listFilesChangedInCommit algorithm closer.
// this might already be fixed by the rework on 11/12/14, but no time to validate at the moment. - doesn't do any harm.
for (final Entry<String, MergeFailOption> r: resolutions.entrySet()) {
if (MergeFailOption.KEEP_REMOTE == r.getValue()) {
filesChangedInCommit.add(r.getKey());
}
if (MergeFailOption.KEEP_LOCAL == r.getValue()) {
filesChangedInCommit.remove(r.getKey());
}
}
if (!StringUtils.isEmptyOrNull(stashIDToApply)) {
log.info("Replaying stash identified in note");
try {
git.stashApply()
.setStashRef(stashIDToApply)
.call();
log.debug("stash applied cleanly, dropping stash");
git.stashDrop()
.call();
} catch (final StashApplyFailureException e) {
log.debug("Stash failed to merge");
addNote(this.NOTE_FAILED_MERGE_HAPPENED_ON_STASH, git);
throw new MergeFailure(git.status().call().getConflicting(), filesChangedInCommit);
}
}
return filesChangedInCommit;
} catch (final GitAPIException e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Status to string.
*
* @param status the status
* @return the string
*/
private String statusToString(Status status) {
final StringBuilder sb = new StringBuilder();
sb.append(" Is clean: ")
.append(status.isClean())
.append(String.format("%n"));
sb.append(" Changed: ")
.append(status.getChanged())
.append(String.format("%n"));
sb.append(" Added: ")
.append(status.getAdded())
.append(String.format("%n"));
sb.append(" Conflicting: ")
.append(status.getConflicting())
.append(String.format("%n"));
sb.append(" Ignored, unindexed: ")
.append(status.getIgnoredNotInIndex())
.append(String.format("%n"));
sb.append(" Missing: ")
.append(status.getMissing())
.append(String.format("%n"));
sb.append(" Modified: ")
.append(status.getModified())
.append(String.format("%n"));
sb.append(" Removed: ")
.append(status.getRemoved())
.append(String.format("%n"));
sb.append(" UncomittedChanges: ")
.append(status.getUncommittedChanges())
.append(String.format("%n"));
sb.append(" Untracked: ")
.append(status.getUntracked())
.append(String.format("%n"));
sb.append(" UntrackedFolders: ")
.append(status.getUntrackedFolders())
.append(String.format("%n"));
return sb.toString();
}
//~--- get methods ---------------------------------------------------------
/**
* Gets the files in merge conflict.
*
* @return the files in merge conflict
* @throws IOException Signals that an I/O exception has occurred.
* @see sh.isaac.api.sync.SyncFiles#getFilesInMergeConflict()
*/
@Override
public Set<String> getFilesInMergeConflict()
throws IOException {
try (Git git = getGit()) {
return git.status()
.call()
.getConflicting();
} catch (final Exception e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
/**
* Gets the git.
*
* @return the git
* @throws IOException Signals that an I/O exception has occurred.
* @throws IllegalArgumentException the illegal argument exception
*/
private Git getGit()
throws IOException, IllegalArgumentException {
if (this.localFolder == null) {
throw new IllegalArgumentException("localFolder has not yet been set - please call setRootLocation(...)");
}
if (!this.localFolder.isDirectory()) {
log.error("The passed in local folder '{}' didn't exist", this.localFolder);
throw new IllegalArgumentException("The localFolder must be a folder, and must exist");
}
final File gitFolder = new File(this.localFolder, ".git");
if (!gitFolder.isDirectory()) {
log.error("The passed in local folder '{}' does not appear to be a git repository", this.localFolder);
throw new IllegalArgumentException("The localFolder does not appear to be a git repository");
}
return Git.open(gitFolder);
}
//~--- set methods ---------------------------------------------------------
/**
* Set the contents of the gitIgnore file.
*
* @param gitIgnoreContent the new git ignore content
*/
public void setGitIgnoreContent(String gitIgnoreContent) {
this.gitIgnoreText = gitIgnoreContent;
}
//~--- get methods ---------------------------------------------------------
/**
* Gets the locally modified file count.
*
* @return the locally modified file count
* @throws IOException Signals that an I/O exception has occurred.
* @see sh.isaac.api.sync.SyncFiles#getLocallyModifiedFileCount()
*/
@Override
public int getLocallyModifiedFileCount()
throws IOException {
try (Git git = getGit()) {
return git.status()
.call()
.getUncommittedChanges()
.size();
} catch (final Exception e) {
log.error("Unexpected", e);
throw new IOException("Internal error", e);
}
}
//~--- set methods ---------------------------------------------------------
/**
* Sets the readme file content.
*
* @param readmeFileContent the new readme file content
* @see sh.isaac.api.sync.SyncFiles#setReadmeFileContent(java.lang.String)
*/
@Override
public void setReadmeFileContent(String readmeFileContent) {
this.readMeFileContent = readmeFileContent;
}
//~--- get methods ---------------------------------------------------------
/**
* Gets the root location.
*
* @return the root location
* @see sh.isaac.api.sync.SyncFiles#getRootLocation()
*/
@Override
public File getRootLocation() {
return this.localFolder;
}
//~--- set methods ---------------------------------------------------------
/**
* Sets the root location.
*
* @param localFolder the new root location
* @throws IllegalArgumentException the illegal argument exception
* @see sh.isaac.api.sync.SyncFiles#setRootLocation(java.io.File)
*/
@Override
public void setRootLocation(File localFolder)
throws IllegalArgumentException {
if (localFolder == null) {
throw new IllegalArgumentException("The localFolder is required");
}
if (!localFolder.isDirectory()) {
log.error("The passed in local folder '{}' didn't exist", localFolder);
throw new IllegalArgumentException("The localFolder must be a folder, and must exist");
}
this.localFolder = localFolder;
}
//~--- get methods ---------------------------------------------------------
/**
* Checks if root location configured for SCM.
*
* @return true, if root location configured for SCM
* @see sh.isaac.api.sync.SyncFiles#isRootLocationConfiguredForSCM()
*/
@Override
public boolean isRootLocationConfiguredForSCM() {
return new File(this.localFolder, ".git").isDirectory();
}
}