com.mindquarry.desktop.workspace.SVNSynchronizer.java Source code

Java tutorial

Introduction

Here is the source code for com.mindquarry.desktop.workspace.SVNSynchronizer.java

Source

/*
 * Copyright (C) 2006-2007 Mindquarry GmbH, All Rights Reserved
 * 
 * The contents of this file are subject to the Mozilla Public License
 * Version 1.1 (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.mozilla.org/MPL/
 *
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
 * License for the specific language governing rights and limitations
 * under the License.
 */
package com.mindquarry.desktop.workspace;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.filefilter.TrueFileFilter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.tigris.subversion.javahl.ClientException;
import org.tigris.subversion.javahl.CommitMessage;
import org.tigris.subversion.javahl.NodeKind;
import org.tigris.subversion.javahl.Notify2;
import org.tigris.subversion.javahl.NotifyAction;
import org.tigris.subversion.javahl.NotifyInformation;
import org.tigris.subversion.javahl.PropertyData;
import org.tigris.subversion.javahl.Revision;
import org.tigris.subversion.javahl.Status;
import org.tigris.subversion.javahl.StatusKind;
import org.tigris.subversion.javahl.Status.Kind;
import org.tmatesoft.svn.core.javahl.SVNClientImpl;

import com.mindquarry.desktop.util.FileHelper;
import com.mindquarry.desktop.util.MimeTypeUtilities;
import com.mindquarry.desktop.util.RelativePath;
import com.mindquarry.desktop.workspace.conflict.Change;
import com.mindquarry.desktop.workspace.conflict.Conflict;
import com.mindquarry.desktop.workspace.conflict.ConflictHandler;
import com.mindquarry.desktop.workspace.conflict.LocalAddition;
import com.mindquarry.desktop.workspace.conflict.LocalDeletion;
import com.mindquarry.desktop.workspace.conflict.LocalModification;
import com.mindquarry.desktop.workspace.conflict.LocalReplace;
import com.mindquarry.desktop.workspace.conflict.RemoteAddition;
import com.mindquarry.desktop.workspace.conflict.RemoteDeletion;
import com.mindquarry.desktop.workspace.conflict.RemoteModification;
import com.mindquarry.desktop.workspace.conflict.RemoteReplace;
import com.mindquarry.desktop.workspace.exception.CancelException;
import com.mindquarry.desktop.workspace.exception.SynchronizeException;

/**
 * Helper class that implements desktop synchronization using the SVN kit. It
 * provides callback hooks for handling conflicts that need user interaction.
 * 
 * Callback handler for conflict resolving must implement the
 * {@link ConflictHandler} interface. A commit message handler must be set by
 * calling {@link setCommitMessageHandler()}.
 * 
 * @author <a href="mailto:saar@mindquarry.com">Alexander Saar</a>
 * @author <a href="mailto:victor.saar@mindquarry.com">Victor Saar</a>
 * @author <a href="mailto:alexander.klimetschek@mindquarry.com">Alexander
 *         Klimetschek</a>
 */
public class SVNSynchronizer {
    private static final Log log = LogFactory.getLog(SVNSynchronizer.class);

    protected String repositoryURL;

    protected File localPathFile;

    protected String username;

    protected String password;

    protected SVNClientImpl client;

    protected ConflictHandler handler;

    /**
     * Constructor for SVNSynchronizer that has all mandatory fields as
     * parameter.
     * 
     * @param repositoryURL
     *            URL of the central SVN repository
     * @param localPath
     *            local working copy path (typically the root of the wc)
     * @param username
     *            subversion username
     * @param password
     *            subversion password
     * @param handler
     *            callback handler to resolve conflicts in the GUI
     */
    public SVNSynchronizer(String repositoryURL, String localPath, String username, String password,
            ConflictHandler handler) {
        log.debug("Creating SVNSynchronizer for " + repositoryURL + ", local path: " + localPath);
        this.repositoryURL = repositoryURL;
        this.username = username;
        this.password = password;
        this.handler = handler;

        this.localPathFile = new File(localPath);

        if (handler == null) {
            throw new NullPointerException("Constructor parameter ConflictHandler handler cannot be null");
        }

        // create SVN client, set authentication info
        client = SVNClientImpl.newInstance();
        if (username != null) {
            client.username(username);
            if (password != null) {
                client.password(password);
            }
        }
    }

    /**
     * Sets an optional notify listener to get notifications directly from the
     * svn client upon update and commit.
     */
    public void setNotifyListener(Notify2 notifyListener) {
        // register for svn notifications on update and commit
        client.notification2(notifyListener);
    }

    public void setCommitMessageHandler(CommitMessage commitMsgHandler) {
        client.commitMessageHandler(commitMsgHandler);
    }

    /**
     * Like synchronize(), but does a checkout if <tt>localPath</tt> isn't a
     * checkout. Also, creates the path if it doesn't exist.
     * 
     * @throws SynchronizeException
     */
    // TODO: make a difference between user cancelled and synch aborted due
    // to some other error
    public void synchronizeOrCheckout() throws SynchronizeException {
        log.debug("synchronizeOrCheckout on " + localPathFile.getAbsolutePath());

        // if directory doesn't exist, create it:
        if (!localPathFile.exists()) {
            boolean createdDir = localPathFile.mkdirs();
            if (!createdDir) {
                throw new RuntimeException("Could not create directory: " + localPathFile.getAbsolutePath());
            }
        }
        if (localPathFile.isFile()) {
            throw new IllegalArgumentException(
                    "File where directory " + "was expected: " + localPathFile.getAbsolutePath());
        }

        boolean isCheckedOut = isCheckedOut(localPathFile);
        if (isCheckedOut) {
            // already check out, sync it
            synchronize();
        } else {
            // check if the directories are empty,
            // otherwise we'd try to check out into a directory
            // that contains local files already which causes
            // confusion.
            Iterator iter = FileUtils.iterateFiles(localPathFile, TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE);
            if (iter.hasNext()) {
                throw new SynchronizeException("Cannot initially checkout into '" + localPathFile.getAbsolutePath()
                        + "' because it seems not empty.");
            } else {
                try {
                    log.debug("checkout " + repositoryURL + " to " + localPathFile.getAbsolutePath());
                    client.checkout(repositoryURL, localPathFile.getAbsolutePath(), Revision.HEAD, true);
                } catch (ClientException e) {
                    throw new RuntimeException(
                            "Checkout of " + repositoryURL + " to " + localPathFile.getAbsolutePath() + " failed",
                            e);
                }
            }
        }
    }

    /**
     * A cleanup is good for removing any old working copy locks (throws only
     * exception if the path does not exist or is not part of a working copy -
     * that has to be checked outside this method)
     * 
     * @throws ClientException
     */
    public void cleanup() throws ClientException {
        log.debug("cleaning up " + localPathFile.getAbsolutePath());
        client.cleanup(localPathFile.getAbsolutePath());
    }

    /**
     * Retrieves local changes for the wc root as a list that is sorted with the
     * top-most folder or file first.
     */
    public List<Status> getLocalChanges() throws ClientException {
        return getLocalChanges(localPathFile);
    }

    /**
     * Important method that returns a list of all changes and conflicts that
     * are to take place when synchronising.
     * 
     * @throws IOException
     */
    public List<Change> getChangesAndConflicts() throws ClientException, IOException {
        deleteMissingAndAddUnversioned(localPathFile);

        List<Status> remoteAndLocalChanges = getRemoteAndLocalChanges();
        List<Status> remoteAndLocalChanges2 = new ArrayList<Status>(remoteAndLocalChanges);
        log.debug("Analyzing changes and conflicts ...");

        for (Status s : remoteAndLocalChanges) {
            log.debug("analyzing " + SVNSynchronizer.textStatusDesc(s.getTextStatus()) + " "
                    + nodeKindDesc(s.getNodeKind()) + " <->" + " "
                    + SVNSynchronizer.textStatusDesc(s.getRepositoryTextStatus()) + " "
                    + nodeKindDesc(s.getReposKind()) + " '" + wcPath(s) + "'");
        }
        List<Change> changes = new ArrayList<Change>();

        // LOCAL status can be everything except:
        // none/normal won't be displayed in local changes
        // unversioned/missing set to added/deleted (handled anyway)
        // merged only happens on update
        // ignored can be ignored ;-)
        // incomplete (on dir) missing files are set to deleted

        // LOCAL status can be any one of those:
        // simple ones:
        // modified
        // added
        // deleted
        // replaced (only possible with svn client)
        // hard ones:
        // conflicted
        // obstructed (eg. deleted file, created dir with same name)
        // external (only possible with svn client)

        // REMOTE status can be only the following:
        // none
        // normal
        // modified
        // added
        // deleted
        // replaced (delete and re-add in one step)

        // content conflicts
        changes.addAll(ConflictHelper.findLocalConflicted(remoteAndLocalChanges));
        changes.addAll(ConflictHelper.findIncomingConflicted(remoteAndLocalChanges));

        // replace conflicts
        changes.addAll(ConflictHelper.findLocalContainerReplacedConflicts(remoteAndLocalChanges));
        changes.addAll(ConflictHelper.findRemoteContainerReplacedConflicts(remoteAndLocalChanges));
        changes.addAll(ConflictHelper.findReplacedModifiedConflicts(remoteAndLocalChanges));

        // add conflicts
        changes.addAll(ConflictHelper.findAddConflicts(remoteAndLocalChanges));

        // delete/modified conflicts
        changes.addAll(ConflictHelper.findLocalContainerDeleteConflicts(client, remoteAndLocalChanges));
        changes.addAll(ConflictHelper.findRemoteContainerDeleteConflicts(remoteAndLocalChanges));
        changes.addAll(ConflictHelper.findFileDeleteModifiedConflicts(remoteAndLocalChanges));
        changes.addAll(ConflictHelper.findFileModifiedDeleteConflicts(remoteAndLocalChanges));

        // property conflicts
        // get up-to-date remote and local changes to get property conflicts of
        // previously removed stati
        changes.addAll(ConflictHelper.findPropertyConflicts(client, remoteAndLocalChanges2));

        // categorize normal changes
        Iterator<Status> iter = remoteAndLocalChanges.iterator();
        while (iter.hasNext()) {
            Status status = iter.next();

            // ----- local -----

            // local addition of files/dirs
            if (status.getTextStatus() == StatusKind.added) {
                iter.remove();
                changes.add(new LocalAddition(new File(status.getPath()), status));
                continue;
            }

            // local deletion of files/dirs
            if (status.getTextStatus() == StatusKind.deleted) {
                iter.remove();
                changes.add(new LocalDeletion(status));
                continue;
            }

            // local modification of files/dirs
            if (status.getTextStatus() == StatusKind.modified) {
                iter.remove();
                changes.add(new LocalModification(status));
                continue;
            }

            // local replace of files/dirs
            if (status.getTextStatus() == StatusKind.replaced) {
                iter.remove();
                changes.add(new LocalReplace(new File(status.getPath()), status));
                continue;
            }
            // ----- remote -----

            // remote addition of files/dirs
            if (status.getRepositoryTextStatus() == StatusKind.added) {
                iter.remove();
                changes.add(new RemoteAddition(new File(status.getPath()), status));
                continue;
            }
            // remote deletion of files/dirs
            if (status.getRepositoryTextStatus() == StatusKind.deleted) {
                iter.remove();
                changes.add(new RemoteDeletion(status));
                continue;
            }
            // remote modification of files/dirs
            if (status.getRepositoryTextStatus() == StatusKind.modified) {
                iter.remove();
                changes.add(new RemoteModification(status));
                continue;
            }
            // remote replace of files/dirs
            if (status.getRepositoryTextStatus() == StatusKind.replaced) {
                iter.remove();
                changes.add(new RemoteReplace(status));
                continue;
            }
        }
        // add normal changes
        log.debug("Detected the following changes:");
        for (Status status : remoteAndLocalChanges) {
            Change change = new Change(status);
            log.debug(change);
            changes.add(change);
        }
        return changes;
    }

    // #########################################################################
    // ### PROTECTED METHODS
    // #########################################################################

    /**
     * Central method: will do a full synchronization, including update and
     * commit. During that the ConflictHandler will be asked. Will fail if
     * there's no checkout yet, see synchronizeOrCheckout(). If the users
     * cancels (i.e. a CancelException is thrown inside a ConflictHandler), the
     * method will end silently.
     * 
     * @throws SynchronizeException
     *             thrown if an unexpected IO, network or SVN error occurs
     */
    protected void synchronize() throws SynchronizeException {
        try {
            log.debug("synchronizing...");
            cleanup();

            // local checks only: conflicted and obstructed
            List<Conflict> localConflicts = analyzeConflictedAndObstructed();
            handleConflictsBeforeRemoteStatus(localConflicts);

            deleteMissingAndAddUnversioned(localPathFile);

            List<Conflict> conflicts = analyzeChangesAndAskUser();

            handleConflictsBeforeUpdate(conflicts);
            log.info("updating " + localPathFile.getAbsolutePath() + " to HEAD");
            client.update(localPathFile.getAbsolutePath(), Revision.HEAD, true);
            handleConflictsAfterUpdate(conflicts);

            localConflicts = analyzeConflicted();
            handleConflictsBeforeCommit(localConflicts);

            // we use the CommitMessage interface as callback
            log.info("committing " + localPathFile.getAbsolutePath());
            long revision = client.commit(new String[] { localPathFile.getAbsolutePath() }, null, true);
            // TODO: what if -1 is returned?
            log.info("committed to revision " + revision);
        } catch (CancelException e) {
            log.info("Cancelled");
            throw new SynchronizeException("synchronize() cancelled: " + e.toString(), e);
        } catch (Exception e) {
            // TODO think about exception handling
            log.error(e);
            if (e.getCause() != null) {
                e.getCause().printStackTrace();
            }
            throw new SynchronizeException("synchronize() failed: " + e.toString(), e);
        }
    }

    // #########################################################################
    // ### PRIVATE/INTERNAL METHODS
    // #########################################################################

    private boolean isCheckedOut(File file) {
        try {
            // throws exception if no .svnref or .svn exists
            client.info(file.getAbsolutePath());
        } catch (ClientException e) {
            // probably not a checkout directory:
            log.info("Not a checked out dir, got exception on " + file.getAbsolutePath() + ": " + e);
            return false;
        }
        log.debug("Is a checked out dir:" + file.getAbsolutePath());
        return true;
    }

    private void presentConflictToUser(Conflict conflict) throws CancelException {
        conflict.setSVNClient(client);

        log.info("-----------------------------------------------------------");
        log.info("## Found conflict: " + conflict.toString());

        // resolve it, ask the user
        conflict.accept(handler);
    }

    /**
     * Retrieves local changes for a wc path as a list that is sorted with the
     * top-most folder or file first.
     */
    private List<Status> getLocalChanges(File file) throws ClientException {
        log.info("## local changes for '" + file.getAbsolutePath() + "':");

        // we need a modifiable list - Arrays.asList is fixed
        List<Status> statusList = new ArrayList<Status>();
        statusList.addAll(Arrays.asList(client.status(file.getAbsolutePath(), true, false, false)));

        // sort the list from top-level folder to bottom which is important
        // for handling multiple conflicts on the parent folder first
        Collections.sort(statusList, new StatusComparator());
        for (Status s : statusList) {
            log.info(textStatusDesc(s.getTextStatus()) + " " + s.getPath());
        }
        return statusList;
    }

    /**
     * Returns a list with all local and remote changes combined. It's not
     * easily possible to get only the remote changes, that's why we use this
     * combined list throughout the code. The status inside this list will be
     * different from the one returned by getLocalChanges() since it might
     * contain the remote change of the same path. The list will be sorted from
     * top to down.
     */
    private List<Status> getRemoteAndLocalChanges() throws ClientException {
        log.info("## remote changes:");

        // we need a modifiable list - Arrays.asList is fixed
        List<Status> statusList = new ArrayList<Status>();
        statusList.addAll(Arrays.asList(client.status(localPathFile.getAbsolutePath(), true, true, false)));

        // sort the list from top-level folder to bottom which is important
        // for handling multiple conflicts on the parent folder first
        Collections.sort(statusList, new StatusComparator());
        for (Status s : statusList) {
            log.info(SVNSynchronizer.textStatusDesc(s.getRepositoryTextStatus()) + " " + s.getPath());
        }
        return statusList;
    }

    /**
     * Merge a new value with an existing or non-existing property value.
     */
    private String mergeIgnoreProperty(PropertyData property, String newValue) {
        List<String> mergedValues = new ArrayList<String>();

        // Note: property might be null, as well as property.getValue()
        if (property == null) {
            return newValue;
        }
        String propVal = property.getValue();
        if (propVal == null) {
            return newValue;
        } else {
            mergedValues.addAll(Arrays.asList(propVal.split("\\n|\\r\\n")));
        }
        if (!mergedValues.contains(newValue)) {
            mergedValues.add(newValue);
        }
        StringBuffer buffer = new StringBuffer();
        for (String value : mergedValues) {
            buffer.append(value + "\n");
        }
        return buffer.toString();
    }

    /**
     * This removes the need for calling svn del and svn add manually. It also
     * automatically adds hidden files (such as Thumbs.db on Windows or
     * .something) to the ignore list.
     */
    private void deleteMissingAndAddUnversioned(File base) throws ClientException, IOException {
        for (Status s : getLocalChanges(base)) {
            log.debug("deleting/adding/ignoring " + SVNSynchronizer.textStatusDesc(s.getTextStatus()) + " "
                    + nodeKindDesc(s.getNodeKind()) + " <->" + " "
                    + SVNSynchronizer.textStatusDesc(s.getRepositoryTextStatus()) + " "
                    + nodeKindDesc(s.getReposKind()) + " '" + wcPath(s) + "'");

            if (s.getTextStatus() == StatusKind.missing) {
                // Note: a missing element could either be an already versioned
                // element or something that was just added. The added variant
                // cannot be diagnosed without asking the server for status
                // information.
                Status remoteStatus = client.singleStatus(s.getPath(), true);
                long remoteRev = -1;
                if (s.getNodeKind() == NodeKind.dir) {
                    // getReposLastCmtRevisionNumber() does not properly work
                    // with files:
                    remoteRev = remoteStatus.getReposLastCmtRevisionNumber();
                } else {
                    // getLastChangedRevisionNumber() does not properly work
                    // with directories:
                    remoteRev = remoteStatus.getLastChangedRevisionNumber();
                }

                if (remoteRev < 0) {
                    log.debug("missing item that was locally added: " + s.getPath());

                    // locally added -> undo add
                    client.revert(s.getPath(), true);
                } else {
                    log.debug("missing item that is already versioned (delete now): " + s.getPath() + ", nodeKind: "
                            + s.getNodeKind());

                    // already versioned -> delete

                    if (s.getNodeKind() == NodeKind.dir) {
                        // we must remove each single file or folder inside dir
                        // (and dir itself) because simply deleting the top dir
                        // will let all subfiles and folders in the 'missing'
                        // state - and upon update they would be re-added, which
                        // is not what we want. We want the missing directory
                        // to be turned into a deleted one without any of the
                        // real dir or files inside be left over because this
                        // would confuse the user

                        log.debug("deleting all subfiles/folders of directory '" + s.getPath() + "':");
                        // this is a status() method with a new parameter
                        // 'showMissing' that will include all subdirs and
                        // subfiles that are below the missing dir
                        Status[] localStati = client.status(s.getPath(), true, false, true, false, false, true);
                        for (Status status : localStati) {
                            client.remove(new String[] { status.getPath() }, null, true);
                        }

                        // Normally, client.remove doesn't delete the directory,
                        // but leaves the empty directory structure behind.
                        // However, since we call this function when refreshing
                        // the workspace changes, empty directories that are
                        // left behind will confuse the user, so we need to make
                        // sure it's really gone. We can reconstruct it later
                        // using our shallow working copy.
                        File dir = new File(s.getPath());
                        if (dir.exists()) {
                            FileUtils.deleteDirectory(dir);
                        }
                    } else {

                        // if the first parameter would be an URL, it would do a
                        // commit (and use the second parameter as commit message) -
                        // but we use a local filesystem path here and thus we only
                        // schedule for a deletion
                        client.remove(new String[] { s.getPath() }, null, true);
                    }
                }

            } else if (s.getTextStatus() == StatusKind.unversioned) {
                // set standard to-be-ignored files
                File file = new File(s.getPath());
                if (file.isHidden()) {
                    log.debug("unversioned item is hidden (ignore now): " + s.getPath());

                    // update the svn:ignore property by appending a new line
                    // with the filename to be ignored (on the parent folder!)
                    PropertyData ignoreProp = client.propertyGet(file.getParent(), PropertyData.IGNORE);

                    if (ignoreProp == null) { // create ignore property
                        client.propertyCreate(file.getParent(), PropertyData.IGNORE, file.getName(), false);
                    } else { // merge ignore property
                        ignoreProp.setValue(mergeIgnoreProperty(ignoreProp, file.getName()), false);
                    }
                } else {
                    log.debug("unversioned item (add now): " + s.getPath());

                    // TODO: check for new files that have the same name when
                    // looking at it case-insensitive (on unix systems) to avoid
                    // problems when checking out on Windows (eg. 'report' is
                    // the same as 'REPORT' under windows, but not on unix).
                    // for this to work we simply check if there is a file with
                    // the same case-insensitive name in this folder, exclude it
                    // from the add and give a warning message to the user
                    // TODO: check for special filename chars (eg. ";" ":" "*")
                    // that are not cross-platform

                    // otherwise we turn all unversioned into added
                    // Do not recurse, we do that ourselve below; we need to
                    // look at each file individually because we want to ignore
                    // some - setting the recurse flag here would add all files
                    // and folders inside the directory
                    client.add(s.getPath(), false);

                    // For directories, we recurse into it: the reason is that
                    // we need to re-retrieve the stati for that directory after
                    // it has been added, because all the unversioned children
                    // are not part of the initial stati list (when the dir is
                    // unversioned). Note: we cannot use isNodeKind() == 'dir'
                    // because svn sees it as 'none' at this point
                    if (new File(s.getPath()).isDirectory()) {
                        deleteMissingAndAddUnversioned(new File(s.getPath()));
                    } else {
                        // For files, we guess the MIME type and set it as a
                        // property. This allows the server to provide more
                        // specific options, such as showing images inline
                        // and displaying more suitable icons for files.
                        // Also, if the svn:mime-type property is
                        // set, then the Subversion Apache module will use its
                        // value to populate the Content-type: HTTP header when
                        // responding to GET requests.
                        String mimeType = MimeTypeUtilities.guessMimetype(s.getPath());
                        log.debug("Setting mime type for " + s.getPath() + ": " + mimeType);
                        client.propertyCreate(s.getPath(), "svn:mime-type", mimeType, false);
                        if (mimeType.startsWith("text/")) {
                            // Causes the file to contain the EOL markers that
                            // are native to the operating system on which
                            // Subversion was run. Subversion will actually
                            // store the file in the repository using normalized
                            // LF EOL markers.
                            client.propertyCreate(s.getPath(), "svn:eol-style", "native", false);
                        }
                    }
                }
            }
        }
    }

    /**
     * Looks for local conflicted files and obstructed files. This only does a
     * local status call, because obstructed files can break a remote status.
     */
    private List<Conflict> analyzeConflictedAndObstructed() throws ClientException {
        List<Conflict> conflicts = new ArrayList<Conflict>();

        List<Status> localChanges = getLocalChanges();
        for (Status s : localChanges) {
            log.debug("locally analyzing " + textStatusDesc(s.getTextStatus()) + " " + nodeKindDesc(s.getNodeKind())
                    + " '" + wcPath(s) + "'");
        }
        conflicts.addAll(ConflictHelper.findLocalObstructed(localChanges));
        conflicts.addAll(ConflictHelper.findLocalConflicted(localChanges));
        return conflicts;
    }

    /**
     * Looks for local conflicted files. This only does a local status call as
     * it happens after the update.
     */
    private List<Conflict> analyzeConflicted() throws ClientException {
        List<Conflict> conflicts = new ArrayList<Conflict>();

        List<Status> localChanges = getLocalChanges();
        for (Status s : localChanges) {
            log.debug("locally analyzing " + textStatusDesc(s.getTextStatus()) + " " + nodeKindDesc(s.getNodeKind())
                    + " '" + wcPath(s) + "'");
        }
        conflicts.addAll(ConflictHelper.findLocalConflicted(localChanges));
        return conflicts;
    }

    /**
     * Handles conflicts that need to be resolved before calling remote status.
     * 
     * @throws IOException
     * @throws CancelException
     */
    private void handleConflictsBeforeRemoteStatus(List<Conflict> localConflicts)
            throws ClientException, IOException, CancelException {
        for (Conflict conflict : localConflicts) {
            presentConflictToUser(conflict);
            log.info(">> Before Remote Status: " + conflict.toString());
            conflict.beforeRemoteStatus();
        }
    }

    /**
     * Handles conflicts that need to be resolved before committing.
     * 
     * @throws IOException
     * @throws CancelException
     */
    private void handleConflictsBeforeCommit(List<Conflict> localConflicts)
            throws ClientException, IOException, CancelException {
        for (Conflict conflict : localConflicts) {
            presentConflictToUser(conflict);
            log.info(">> Before Commit: " + conflict.toString());
            conflict.beforeCommit();
        }
    }

    /**
     * Important method that looks out for any structure conflicts before an
     * update and creates {@link Conflict} objects for those. Upon each conflict
     * found, the user is asked to resolve it.
     * 
     * If the user cancels during the conflict resolving, a CancelException is
     * thrown.
     */
    private List<Conflict> analyzeChangesAndAskUser() throws ClientException {
        List<Status> remoteAndLocalChanges = getRemoteAndLocalChanges();

        List<Conflict> conflicts = new ArrayList<Conflict>();

        for (Status s : remoteAndLocalChanges) {
            log.debug("analyzing " + SVNSynchronizer.textStatusDesc(s.getTextStatus()) + " "
                    + nodeKindDesc(s.getNodeKind()) + " <->" + " "
                    + SVNSynchronizer.textStatusDesc(s.getRepositoryTextStatus()) + " "
                    + nodeKindDesc(s.getReposKind()) + " '" + wcPath(s) + "'");
        }

        // LOCAL status can be everything except:
        // none/normal won't be displayed in local changes
        // unversioned/missing set to added/deleted (handled anyway)
        // merged only happens on update
        // ignored can be ignored ;-)
        // incomplete (on dir) missing files are set to deleted

        // LOCAL status can be any one of those:
        // simple ones:
        // modified
        // added
        // deleted
        // replaced (only possible with svn client)
        // hard ones:
        // conflicted
        // obstructed (eg. deleted file, created dir with same name)
        // external (only possible with svn client)

        // REMOTE status can be only the following:
        // none
        // normal
        // modified
        // added
        // deleted
        // replaced (delete and re-add in one step)

        // replace conflicts
        conflicts.addAll(ConflictHelper.findLocalContainerReplacedConflicts(remoteAndLocalChanges));
        conflicts.addAll(ConflictHelper.findRemoteContainerReplacedConflicts(remoteAndLocalChanges));
        conflicts.addAll(ConflictHelper.findReplacedModifiedConflicts(remoteAndLocalChanges));

        // add conflicts
        conflicts.addAll(ConflictHelper.findAddConflicts(remoteAndLocalChanges));

        // delete/modified conflicts
        conflicts.addAll(ConflictHelper.findLocalContainerDeleteConflicts(client, remoteAndLocalChanges));
        conflicts.addAll(ConflictHelper.findRemoteContainerDeleteConflicts(remoteAndLocalChanges));
        conflicts.addAll(ConflictHelper.findFileDeleteModifiedConflicts(remoteAndLocalChanges));
        conflicts.addAll(ConflictHelper.findFileModifiedDeleteConflicts(remoteAndLocalChanges));

        // property conflicts
        // get up-to-date remote and local changes to get property conflicts of
        // previously removed stati
        conflicts.addAll(ConflictHelper.findPropertyConflicts(client, getRemoteAndLocalChanges()));
        return conflicts;
    }

    /**
     * Calls {@link Conflict.handleBeforeUpdate} on all conflicts in the list.
     * 
     * @throws IOException
     * @throws CancelException
     */
    private void handleConflictsBeforeUpdate(List<Conflict> conflicts)
            throws ClientException, IOException, CancelException {
        for (Conflict conflict : conflicts) {
            presentConflictToUser(conflict);
            log.info(">> Before Update: " + conflict.toString());
            conflict.beforeUpdate();
        }
    }

    /**
     * Calls {@link Conflict.handleAfterUpdate} on all conflicts in the list.
     * 
     * @throws IOException
     */
    private void handleConflictsAfterUpdate(List<Conflict> conflicts) throws ClientException, IOException {
        for (Conflict conflict : conflicts) {
            log.info(">> After Update: " + conflict.toString());
            conflict.afterUpdate();
        }
    }

    /**
     * Returns the relative path in the working copy of the status object (for
     * shorter strings in log output).
     */
    private String wcPath(Status status) {
        return RelativePath.getRelativePath(localPathFile, new File(status.getPath()));
    }

    // #########################################################################
    // ### INNER CLASSES
    // #########################################################################

    private class StatusComparator implements Comparator<Status> {
        public int compare(Status left, Status right) {
            return left.getPath().compareTo(right.getPath());
        }
    }

    // #########################################################################
    // ### STATIC METHODS
    // #########################################################################

    /**
     * Gets all children and grand-children and so on for the path.
     */
    public static List<Status> getChildren(String path, List<Status> remoteAndLocalChanges) {
        List<Status> result = new ArrayList<Status>();
        // FIXME: not the fastest way (iterate over all + isParent for each)
        for (Status s : remoteAndLocalChanges) {
            if (FileHelper.isParent(path, s.getPath())) {
                result.add(s);
            }
        }
        return result;
    }

    /**
     * Helper method that stringifies a notify object from the notify callback
     * of svnkit.
     */
    public static String notifyToString(NotifyInformation info) {
        if (info.getAction() == -11) {
            // see org.tigris.subversion.javahl.JavaHLObjectFactory:
            // "undocumented thing"
            return "commit completed";
        } else if (info.getAction() < 0 || info.getAction() >= NotifyAction.actionNames.length) {
            return info.getAction() + " " + info.getPath() + " " + info.getErrMsg();
        } else {
            return NotifyAction.actionNames[info.getAction()] + " " + info.getPath();
        }
    }

    /**
     * Returns the given nodekind int value as a human-readable string (eg.
     * "file" or "dir").
     */
    public static String nodeKindDesc(int nodeKind) {
        if (nodeKind == NodeKind.file) {
            return "file";
        } else if (nodeKind == NodeKind.dir) {
            return "dir";
        } else if (nodeKind == NodeKind.none) {
            return "none";
        } else {
            return "unknown";
        }
    }

    /**
     * Returns a human-readable string for the text status - fixes the missing
     * 'obstructed' case in Kind.getDescription(int).
     */
    public static String textStatusDesc(int kind) {
        switch (kind) {
        case StatusKind.obstructed:
            return "obstructed";

        default:
            return Kind.getDescription(kind);
        }
    }
}