gov.va.isaac.sync.git.SyncServiceGIT.java Source code

Java tutorial

Introduction

Here is the source code for gov.va.isaac.sync.git.SyncServiceGIT.java

Source

/**
 * Copyright Notice
 *
 * This is a work of the U.S. Government and is not subject to copyright
 * protection in the United States. Foreign copyrights may apply.
 * 
 * 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 gov.va.isaac.sync.git;

import gov.va.isaac.interfaces.sync.MergeFailOption;
import gov.va.isaac.interfaces.sync.MergeFailure;
import gov.va.isaac.interfaces.sync.ProfileSyncI;
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;
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.UsernamePasswordCredentialsProvider;
import org.eclipse.jgit.transport.OpenSshConfig.Host;
import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.transport.SshSessionFactory;
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;

/**
 * {@link SyncServiceGIT}
 * 
 * A GIT implementation of {@link ProfileSyncI}
 *
 * @author <a href="mailto:daniel.armbrust.list@gmail.com">Dan Armbrust</a>
 */
@Service(name = gov.va.isaac.interfaces.gui.constants.SharedServiceNames.GIT)
@PerLookup
public class SyncServiceGIT implements ProfileSyncI {
    private static Logger log = LoggerFactory.getLogger(SyncServiceGIT.class);

    private final String eol = System.getProperty("line.separator");
    private final String NOTE_FAILED_MERGE_HAPPENED_ON_REMOTE = "Conflicted merge happened during remote merge";
    private final String NOTE_FAILED_MERGE_HAPPENED_ON_STASH = "Conflicted merge happened during stash merge";
    private final String STASH_MARKER = ":STASH-";

    private static volatile CountDownLatch jschConfigured = new CountDownLatch(1);

    private File localFolder = null;
    private String readMeFileContent_ = DEFAULT_README_CONTENT;

    private SyncServiceGIT() {
        //Constructor for HK2

        synchronized (jschConfigured) {
            if (jschConfigured.getCount() > 0) {
                log.debug("Disabling strict host key checking");
                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 HashMap<Integer, Consumer<String>> logMap = new HashMap<>();
                    private HashMap<Integer, BooleanSupplier> enabledMap = new HashMap<>();

                    {
                        //Note- JSCH is _really_  verbose at the INFO level, so I'm mapping info to DEBUG.
                        logMap.put(com.jcraft.jsch.Logger.DEBUG, log::debug);
                        logMap.put(com.jcraft.jsch.Logger.ERROR, log::error);
                        logMap.put(com.jcraft.jsch.Logger.FATAL, log::error);
                        logMap.put(com.jcraft.jsch.Logger.INFO, log::debug);
                        logMap.put(com.jcraft.jsch.Logger.WARN, log::warn);

                        enabledMap.put(com.jcraft.jsch.Logger.DEBUG, log::isDebugEnabled);
                        enabledMap.put(com.jcraft.jsch.Logger.ERROR, log::isErrorEnabled);
                        enabledMap.put(com.jcraft.jsch.Logger.FATAL, log::isErrorEnabled);
                        enabledMap.put(com.jcraft.jsch.Logger.INFO, log::isDebugEnabled);
                        enabledMap.put(com.jcraft.jsch.Logger.WARN, log::isWarnEnabled);
                    }

                    @Override
                    public void log(int level, String message) {
                        logMap.get(level).accept(message);
                    }

                    @Override
                    public boolean isEnabled(int level) {
                        return enabledMap.get(level).getAsBoolean();
                    }
                });
                jschConfigured.countDown();
            }
        }
    }

    /**
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#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;
    }

    /**
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#setReadmeFileContent(java.lang.String)
     */
    @Override
    public void setReadmeFileContent(String readmeFileContent) {
        readMeFileContent_ = readmeFileContent;

    }

    /**
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#getRootLocation()
     */
    @Override
    public File getRootLocation() {
        return this.localFolder;
    }

    /**
     * @throws AuthenticationException 
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#linkAndFetchFromRemote(java.io.File, java.lang.String, java.lang.String, java.lang.String)
     */
    @Override
    public void linkAndFetchFromRemote(String remoteAddress, String username, String password)
            throws IllegalArgumentException, IOException, AuthenticationException {
        log.info("linkAndFetchFromRemote called - folder: {}, remoteAddress: {}, username: {}", localFolder,
                remoteAddress, username);
        try {
            File gitFolder = new File(localFolder, ".git");
            Repository 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 git = new Git(r);

            CredentialsProvider cp = new UsernamePasswordCredentialsProvider(username,
                    (password == null ? new char[] {} : password.toCharArray()));

            log.debug("Fetching");
            FetchResult fr = git.fetch().setCheckFetchedObjects(true).setCredentialsProvider(cp).call();
            log.debug("Fetch messages: {}", fr.getMessages());

            boolean remoteHasMaster = false;
            Collection<Ref> refs = git.lsRemote().setCredentialsProvider(cp).call();
            for (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");
                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 (String missing : git.status().call().getMissing()) {
                    log.debug("Checkout {}", missing);
                    git.checkout().addPath(missing).call();
                }

                for (String newFile : makeInitialFilesAsNecessary(localFolder)) {
                    log.debug("Adding and committing {}", newFile);
                    git.add().addFilepattern(newFile).call();
                    git.commit().setMessage("Adding " + newFile).setAuthor(username, "42").call();

                    for (PushResult pr : git.push().setCredentialsProvider(cp).call()) {
                        log.debug("Push Message: {}", pr.getMessages());
                    }
                }
            } else {
                //just push
                //make sure we have something to push
                for (String newFile : makeInitialFilesAsNecessary(localFolder)) {
                    log.debug("Adding and committing {}", newFile);
                    git.add().addFilepattern(newFile).call();
                    git.commit().setMessage("Adding readme file").setAuthor(username, "42").call();
                }

                log.debug("Pushing repository");
                for (PushResult pr : git.push().setCredentialsProvider(cp).call()) {
                    log.debug("Push Result: {}", pr.getMessages());
                }
            }

            log.info("linkAndFetchFromRemote Complete.  Current status: " + statusToString(git.status().call()));
        } catch (TransportException te) {
            if (te.getMessage().contains("Auth fail")) {
                log.info("Auth fail", te);
                throw new AuthenticationException("Auth fail");
            } else {
                log.error("Unexpected", te);
                throw new IOException("Internal error", te);
            }
        } catch (GitAPIException e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

    /**
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#relinkRemote(java.lang.String, java.lang.String, java.lang.String)
     */
    @Override
    public void relinkRemote(String remoteAddress, String username, String password)
            throws IllegalArgumentException, IOException {
        log.debug("Configuring remote URL and fetch defaults to {}", remoteAddress);
        StoredConfig sc = getGit().getRepository().getConfig();
        sc.setString("remote", "origin", "url", remoteAddress);
        sc.setString("remote", "origin", "fetch", "+refs/heads/*:refs/remotes/origin/*");
        sc.save();
    }

    /**
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#addFiles(java.io.File, java.util.Set)
     */
    @Override
    public void addFiles(String... files) throws IllegalArgumentException, IOException {
        try {
            log.info("Add Files called {}", Arrays.toString(files));
            Git git = getGit();
            if (files.length == 0) {
                log.debug("No files to add");
            } else {
                AddCommand ac = git.add();
                for (String file : files) {
                    ac.addFilepattern(file);
                }
                ac.call();
            }
            log.info("addFiles Complete.  Current status: " + statusToString(git.status().call()));
        } catch (GitAPIException e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

    /**
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#removeFiles(java.io.File, java.util.Set)
     */
    @Override
    public void removeFiles(String... files) throws IllegalArgumentException, IOException {
        try {
            log.info("Remove Files called {}", Arrays.toString(files));
            Git git = getGit();
            if (files.length == 0) {
                log.debug("No files to remove");
            } else {
                RmCommand rm = git.rm();
                for (String file : files) {
                    rm.addFilepattern(file);
                }
                rm.call();
            }
            log.info("removeFiles Complete.  Current status: " + statusToString(git.status().call()));
        } catch (GitAPIException e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

    /**
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#addUntrackedFiles(java.io.File)
     */
    @Override
    public void addUntrackedFiles() throws IllegalArgumentException, IOException {
        log.info("Add Untracked files called");
        try {
            Git git = getGit();
            Status s = git.status().call();

            addFiles(s.getUntracked().toArray(new String[s.getUntracked().size()]));
        } catch (GitAPIException e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

    /**
     * @throws MergeFailure 
     * @throws AuthenticationException 
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#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, String password,
            MergeFailOption mergeFailOption, String... files)
            throws IllegalArgumentException, IOException, MergeFailure, AuthenticationException {
        try {
            log.info("Commit Files called {}", (files == null ? "-null-" : Arrays.toString(files)));
            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) {
                CommitCommand commit = git.commit();
                for (String file : files) {
                    commit.setOnly(file);
                }

                commit.setAuthor(username, "42");
                commit.setMessage(commitMessage);
                RevCommit rv = commit.call();
                log.debug("Local commit completed: " + rv.getFullMessage());
            }

            //need to merge origin/master into master now, prior to push
            Set<String> result = updateFromRemote(username, password, mergeFailOption);

            log.debug("Pushing");
            CredentialsProvider cp = new UsernamePasswordCredentialsProvider(username,
                    (password == null ? new char[] {} : password.toCharArray()));

            Iterable<PushResult> pr = git.push().setCredentialsProvider(cp).call();
            pr.forEach(new Consumer<PushResult>() {
                @Override
                public void accept(PushResult t) {
                    log.debug("Push Result Messages: " + t.getMessages());
                }
            });

            log.info("commit and push complete.  Current status: " + statusToString(git.status().call()));
            return result;
        } catch (TransportException te) {
            if (te.getMessage().contains("Auth fail")) {
                log.info("Auth fail", te);
                throw new AuthenticationException("Auth fail");
            } else {
                log.error("Unexpected", te);
                throw new IOException("Internal error", te);
            }
        } catch (GitAPIException e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

    /**
     * @throws MergeFailure
     * @throws AuthenticationException 
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#updateFromRemote(java.io.File, java.lang.String, java.lang.String,
     * gov.va.isaac.interfaces.sync.MergeFailOption)
     */
    @Override
    public Set<String> updateFromRemote(String username, String password, MergeFailOption mergeFailOption)
            throws IllegalArgumentException, IOException, MergeFailure, AuthenticationException {
        Set<String> filesChangedDuringPull;
        try {
            log.info("update from remote called ");

            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<>());
            }

            CredentialsProvider cp = new UsernamePasswordCredentialsProvider(username,
                    (password == null ? new char[] {} : password.toCharArray()));
            log.debug("Fetch Message" + git.fetch().setCredentialsProvider(cp).call().getMessages());

            ObjectId masterIdBeforeMerge = git.getRepository().getRef("master").getObjectId();
            if (git.getRepository().getRef("refs/remotes/origin/master").getObjectId().getName()
                    .equals(masterIdBeforeMerge.getName())) {
                log.info("No changes to merge");
                return new HashSet<String>();
            }

            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");
                MergeResult mr = git.merge().include(git.getRepository().getRef("refs/remotes/origin/master"))
                        .call();
                AnyObjectId headAfterMergeID = mr.getNewHead();

                if (!mr.getMergeStatus().isSuccessful()) {
                    if (mergeFailOption == null || MergeFailOption.FAIL == mergeFailOption) {
                        addNote(NOTE_FAILED_MERGE_HAPPENED_ON_REMOTE
                                + (stash == null ? ":NO_STASH" : 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) {
                        HashMap<String, MergeFailOption> resolutions = new HashMap<>();
                        for (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 (StashApplyFailureException e) {
                    log.debug("Stash failed to merge");
                    if (mergeFailOption == null || MergeFailOption.FAIL == mergeFailOption) {
                        addNote(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) {
                        HashMap<String, MergeFailOption> resolutions = new HashMap<>();
                        for (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 (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 (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 (TransportException te) {
            if (te.getMessage().contains("Auth fail")) {
                log.info("Auth fail", te);
                throw new AuthenticationException("Auth fail");
            } else {
                log.error("Unexpected", te);
                throw new IOException("Internal error", te);
            }
        } catch (GitAPIException e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

    /**
     * @throws MergeFailure 
     * @throws NoWorkTreeException 
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#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();

            List<Note> notes = git.notesList().call();

            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 (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");
            }

            String noteValue = new String(git.getRepository().open(notes.get(0).getData()).getBytes());

            MergeFailType mergeFailType;
            if (noteValue.startsWith(NOTE_FAILED_MERGE_HAPPENED_ON_REMOTE)) {
                mergeFailType = MergeFailType.REMOTE_TO_LOCAL;
            } else if (noteValue.startsWith(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(STASH_MARKER)) {
                stashIdToApply = noteValue.substring(noteValue.indexOf(STASH_MARKER) + STASH_MARKER.length());
            }

            return resolveMergeFailures(mergeFailType, stashIdToApply, resolutions);
        } catch (GitAPIException | LargeObjectException e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

    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 (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();
            }

            RevWalk walk = new RevWalk(git.getRepository());
            Ref head = git.getRepository().getRef("refs/heads/master");
            RevCommit commitWithPotentialNote = walk.parseCommit(head.getObjectId());

            log.info("resolve merge failures Complete.  Current status: " + statusToString(git.status().call()));

            RevCommit rc = git.commit().setMessage(
                    "Merging with user specified merge failure resolution for files " + resolutions.keySet())
                    .call();

            git.notesRemove().setObjectId(commitWithPotentialNote).call();
            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 (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 (StashApplyFailureException e) {
                    log.debug("Stash failed to merge");
                    addNote(NOTE_FAILED_MERGE_HAPPENED_ON_STASH, git);
                    throw new MergeFailure(git.status().call().getConflicting(), filesChangedInCommit);
                }
            }

            return filesChangedInCommit;
        } catch (GitAPIException e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

    private void addNote(String message, Git git) throws IOException, GitAPIException {
        RevWalk walk = new RevWalk(git.getRepository());
        Ref head = git.getRepository().getRef("refs/heads/master");
        RevCommit commit = walk.parseCommit(head.getObjectId());
        git.notesAdd().setObjectId(commit).setMessage(message).call();

    }

    private HashSet<String> listFilesChangedInCommit(Repository repository, AnyObjectId beforeID,
            AnyObjectId afterID) throws MissingObjectException, IncorrectObjectTypeException, IOException {
        log.info("calculating files changed in commit");
        HashSet<String> result = new HashSet<>();
        RevWalk rw = new RevWalk(repository);
        RevCommit commitBefore = rw.parseCommit(beforeID);
        RevCommit commitAfter = rw.parseCommit(afterID);
        DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE);
        df.setRepository(repository);
        df.setDiffComparator(RawTextComparator.DEFAULT);
        df.setDetectRenames(true);
        List<DiffEntry> diffs = df.scan(commitBefore.getTree(), commitAfter.getTree());
        for (DiffEntry diff : diffs) {
            result.add(diff.getNewPath());
        }
        log.debug("Files changed between commits commit: {} and {} - {}", beforeID.getName(), afterID, result);
        return result;
    }

    private Git getGit() throws IOException, IllegalArgumentException {
        if (localFolder == null) {
            throw new IllegalArgumentException(
                    "localFolder has not yet been set - please call setRootLocation(...)");
        }
        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");
        }

        File gitFolder = new File(localFolder, ".git");

        if (!gitFolder.isDirectory()) {
            log.error("The passed in local folder '{}' does not appear to be a git repository", localFolder);
            throw new IllegalArgumentException("The localFolder does not appear to be a git repository");
        }
        return new Git(new FileRepository(gitFolder));
    }

    private String statusToString(Status status) {
        StringBuilder sb = new StringBuilder();
        sb.append("Is clean: " + status.isClean() + eol);
        sb.append("Changed: " + status.getChanged() + eol);
        sb.append("Added: " + status.getAdded() + eol);
        sb.append("Conflicting: " + status.getConflicting() + eol);
        sb.append("Ignored, unindexed: " + status.getIgnoredNotInIndex() + eol);
        sb.append("Missing: " + status.getMissing() + eol);
        sb.append("Modified: " + status.getModified() + eol);
        sb.append("Removed: " + status.getRemoved() + eol);
        sb.append("UncomittedChanges: " + status.getUncommittedChanges() + eol);
        sb.append("Untracked: " + status.getUntracked() + eol);
        sb.append("UntrackedFolders: " + status.getUntrackedFolders() + eol);
        return sb.toString();
    }

    /**
     * returns a list of newly created files and files that were modified.
     */
    private List<String> makeInitialFilesAsNecessary(File containingFolder) throws IOException {
        ArrayList<String> result = new ArrayList<>();
        File readme = new File(containingFolder, "README.md");
        if (!readme.isFile()) {
            log.debug("Creating {}", readme.getAbsolutePath());
            Files.write(readme.toPath(), new String(readMeFileContent_).getBytes(), StandardOpenOption.CREATE_NEW);
            result.add(readme.getName());
        } else {
            log.debug("README.md already exists");
        }

        File ignore = new File(containingFolder, ".gitignore");
        if (!ignore.isFile()) {
            log.debug("Creating {}", ignore.getAbsolutePath());
            Files.write(ignore.toPath(), new String("lastUser.txt\r\n").getBytes(), StandardOpenOption.CREATE_NEW);
            result.add(ignore.getName());
        } else {
            log.debug(".gitignore already exists");

            if (!new String(Files.readAllBytes(ignore.toPath())).contains("lastUser.txt")) {
                log.debug("Appending onto existing .gitignore file");
                Files.write(ignore.toPath(), new String("\r\nlastUser.txt\r\n").getBytes(),
                        StandardOpenOption.APPEND);
                result.add(ignore.getName());
            }
        }
        return result;
    }

    /**
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#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("@")) {
            int index = url.indexOf("@");
            url = "ssh://" + username + url.substring(index);
        }
        return url;
    }

    /**
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#isRootLocationConfiguredForSCM()
     */
    @Override
    public boolean isRootLocationConfiguredForSCM() {
        return new File(localFolder, ".git").isDirectory();
    }

    /**
     * @throws IOException 
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#getLocallyModifiedFileCount()
     */
    @Override
    public int getLocallyModifiedFileCount() throws IOException {
        try {
            return getGit().status().call().getUncommittedChanges().size();
        } catch (Exception e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

    /**
     * @throws IOException 
     * @see gov.va.isaac.interfaces.sync.ProfileSyncI#getFilesInMergeConflict()
     */
    @Override
    public Set<String> getFilesInMergeConflict() throws IOException {
        try {
            return getGit().status().call().getConflicting();
        } catch (Exception e) {
            log.error("Unexpected", e);
            throw new IOException("Internal error", e);
        }
    }

}