/*****************************************************************************
This file is part of Git-Starteam.
Git-Starteam is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Git-Starteam is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Git-Starteam. If not, see <http://www.gnu.org/licenses/>.
******************************************************************************/
package org.sync.commitstrategy;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.NavigableMap;
import java.util.Set;
import java.util.TreeMap;
import com.starbase.starteam.*;
import org.sync.CommitPopulationStrategy;
import org.sync.Log;
import org.sync.RenameFinder;
import org.sync.RepositoryHelper;
import org.sync.util.CommitInformation;
import org.sync.util.Pair;
public class BasePopulationStrategy implements CommitPopulationStrategy {
/// View on which operation of file population will take place.
protected View currentView;
protected HashSet<String> lastFiles;
protected HashSet<String> deletedFiles;
protected TreeMap<CommitInformation, File> currentCommitList;
private RepositoryHelper helper;
private int initialFileVersion;
protected java.util.Date earliestTime;
protected boolean verbose;
/**
* Base Population strategy constructor using a view as its base of operations
* to iterate around the files to construct the list of commit to reproduce in
* git. The algorithms are pretty generic and have plenty of override point to
* complet with more information.
*
* @param view
* The view where we shall collect ours commits informations
*/
public BasePopulationStrategy(View view) {
currentView = view;
lastFiles = new HashSet<String>();
deletedFiles = new HashSet<String>();
currentCommitList = new TreeMap<CommitInformation, File>();
verbose = false;
earliestTime = new java.util.Date(0);
// We register new files with version -1 to be sure to add it. Since this is
// a discovered file, when we are going to pass trough the files, we will
// make sure to get it's version 1. Setting the following value to 0 would
// grab all the version of the files since its creation
initialFileVersion = -1;
}
@Override
public void filePopulation(String head, Folder root) {
currentCommitList.clear(); // flush every composed commit from last run.
deletedFiles.clear();
deletedFiles.addAll(lastFiles);
populateStarteamProperties(root);
doFilePopulation(head, "", root);
lastFiles.removeAll(deletedFiles); // clean files that was never seen from the last files.
recoverDeleteInformation(head, root);
if (currentCommitList.size() > 0) {
setLastCommitTime(currentCommitList.lastKey().getCommitDate());
}
}
/**
* @param root
* The root folder requiring the properties population
*/
protected void populateStarteamProperties(Folder root) {
PropertyNames propNames = root.getPropertyNames();
// Those are the interesting properties that we need.
// Those will prevent back-and-forth with the server regarding the
// collection of information.
String[] populateProps = new String[] {
propNames.FILE_NAME,
propNames.COMMENT,
propNames.FILE_DESCRIPTION,
propNames.FILE_CONTENT_REVISION,
propNames.MODIFIED_TIME,
propNames.MODIFIED_USER_ID,
propNames.EXCLUSIVE_LOCKER,
propNames.NON_EXCLUSIVE_LOCKERS,
propNames.FILE_ENCODING,
propNames.FILE_EOL_CHARACTER,
propNames.FILE_EXECUTABLE,
propNames.PATH_REVISION,
};
root.populateNow(currentView.getServer().getTypeNames().FILE, populateProps, -1);
}
/**
* Find out all files to be added into the commit list.
*
* @param head
* the head in which we should check for file content modification
* @param gitpath
* the current path based on the root folder
* @param f
* the folder to grab files from
*/
protected void doFilePopulation(String head, String gitpath, Folder f) {
if (null == head) {
throw new NullPointerException("Head cannot be null");
}
if (null == f) {
throw new NullPointerException("Folder cannot be null");
}
for(Item i : f.getItems(f.getTypeNames().FILE)) {
if(i instanceof File) {
File historyFile = (File) i;
String path = gitpath + (gitpath.length() > 0 ? "/" : "") + historyFile.getName();
processFileForCommit(head, historyFile, path);
} else {
Log.log("Item " + f + "/" + i + " is not a file");
}
}
for(Folder subfolder : f.getSubFolders()) {
String newGitPath = gitpath + (gitpath.length() > 0 ? "/" : "") + subfolder.getName();
doFilePopulation(head, newGitPath, subfolder);
}
}
/***
* Process an individual file to create a commit in relation with its
* modifications
*
* @param head
* The target branch name
* @param historyFile
* The file from the history we need to process
* @param path
* The path where the file will be located in the git repository
*/
protected void processFileForCommit(String head, File historyFile, String path) {
Integer fileid = helper.getRegisteredFileId(head, path);
Integer previousVersion = -1;
Integer previousContentVersion = -1;
if (null == fileid) {
helper.registerFileId(head, path, historyFile.getItemID(), initialFileVersion);
} else {
// fetch the previous version we did register so we continue
// modification from that point in time.
previousVersion = helper.getRegisteredFileVersion(head, path);
previousContentVersion = helper.getRegisteredFileContentVersion(head, path);
}
if (deletedFiles.contains(path)) {
deletedFiles.remove(path);
}
if (!lastFiles.contains(path)) {
lastFiles.add(path);
}
// prefer content version as it is cached in the File object
int itemViewVersion = historyFile.getContentVersion();
if (fileid != null && fileid != historyFile.getItemID()) {
Log.logf("File %s was replaced", path);
createCommitInformation(path, historyFile, 1);
} else if (previousContentVersion < 0) {
createCommitInformation(path, historyFile, 1);
} else if (previousContentVersion > itemViewVersion) {
Log.logf("File %s was reverted from version %d to %d was skipped", path, previousContentVersion,
itemViewVersion);
createCommitInformation(path, historyFile, 1);
} else {
// To get a better feel of all modification that did occurs in the history, get each version of the files that we
// didn't see in the past.
int iterationCounter = 1;
int viewVersion = historyFile.getViewVersion();
for (int ver = previousVersion + 1; ver <= viewVersion;) {
// If theirs is a concurrent acces to the file, we need to retry again
// later.
try {
File fromHistory = (File) historyFile.getFromHistoryByVersion(ver);
if (fromHistory != null) {
// iterationCounter only serve as an helper in case the multiple
// version of the same file are done in the past
createCommitInformation(path, fromHistory, iterationCounter++);
} else {
Log.logf("File %s doesn't have a view version #%d, started iteration at version %d", path, ver,
previousVersion + 1);
}
ver++;
} catch (ServerException e) {
Log.logf("Failed to get revision %d of file %s will try again", ver, path);
try {
Thread.sleep(100);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
}
}
}
/**
* This method make sure that the found item is part of the current root we
* are scanning. Sometime, when a file is still in the view but is present
* somewhere else in the view but not as a sub element of the current root.
*
* This simple iterative method should be able to confirm the file is a child
* of the requested folder.
*
* @param aFile
* the leaf file we are querying
* @param aPotentialParent
* the expected parent to find
* @return true if the file is the child of the provided parent.
**/
protected boolean isChildOf(File aFile, Folder aPotentialParent) {
Folder parent = aFile.getParentFolder();
int searchedVersion = aPotentialParent.getViewVersion();
while (parent != null) {
if (parent.getObjectID() == aPotentialParent.getObjectID()) {
return parent.getViewVersion() == searchedVersion;
}
parent = parent.getParentFolder();
}
return false;
}
/**
* This method is used to extract the pathname based on the root of the
* conversion that we are doing. The path is created by backtracking the
* parent until we find the provided root.
*
* @param aFile
* the file we need to find the correct path from
* @param root
* the base folder to which we need to backtrack the path
* @return a git path to the file
*/
private String pathname(File aFile, Folder root) {
ArrayList<CharSequence> pathComponent = new ArrayList<CharSequence>();
pathComponent.add(0, aFile.getName());
Folder parent = aFile.getParentFolder();
boolean foundCommonParent = false;
while (parent != null) {
if (parent.getObjectID() == root.getObjectID()) {
foundCommonParent = true;
break;
}
pathComponent.add(0, parent.getName());
parent = parent.getParentFolder();
}
if (!foundCommonParent) {
throw new RuntimeException("Could not find the comment path between " +
aFile.getParentFolderHierarchy() + "/" + aFile.getName() +
" and " + root.getName());
}
StringBuilder joiner = new StringBuilder();
joiner.append(pathComponent.get(0));
for (int i = 1; i < pathComponent.size(); i++) {
joiner.append("/").append(pathComponent.get(i));
}
return joiner.toString();
}
/**
* Determines what happened to files which were in the view during the
* previous recursiveFilePopulation run but were not found in the current run.
*
* Deleted files will be found in the view recycle bin. Otherwise the file was
* renamed.
*
* @param head
* which git head we are refering to
* @param root
* base root folder we are importing from
*/
private void recoverDeleteInformation(String head, Folder root) {
RecycleBin recycleBin = null;
Type fileType = currentView.getServer().typeForName(currentView.getTypeNames().FILE);
try {
recycleBin = root.getView().getRecycleBin();
recycleBin.setIncludeDeletedItems(true);
fileType = currentView.getServer().typeForName(recycleBin.getTypeNames().FILE);
} catch (java.lang.UnsupportedOperationException e) {
recycleBin = null;
fileType = currentView.getServer().typeForName(currentView.getTypeNames().FILE);
}
RenameFinder renameFinder = new RenameFinder();
ArrayList<Pair<String, File>> deletedpaths = new ArrayList<Pair<String, File>>(deletedFiles.size());
// No need to call populateNow on recycleBin.getRootFolder(), as that
// is done by recycleBin.findItem(). If we called it now, we would
// incur a long wait which we may not need.
for(Iterator<String> ith = deletedFiles.iterator(); ith.hasNext(); ) {
String path = ith.next();
Integer fileID = helper.getRegisteredFileId(head, path);
if(null != fileID) {
File item = null;
if (null == recycleBin ) {
item = null;
} else {
try {
item = (File) recycleBin.findItem(fileType, fileID);
} catch (ServerException e) {
Log.logf("Coulfd not find deleted files <%s> ID: %d [%s]", path, fileID, e.getMessage());
}
}
if(null != item && item.isDeleted()) {
deletedpaths.add(new Pair<String, File>(path, item));
ith.remove();
} else {
item = (File) root.getView().findItem(fileType, fileID);
if(null != item && isChildOf(item, root)) {
CommitInformation deleteInfo;
String newPath = pathname(item, root);
Item renameEventItem = renameFinder.findEventItem(currentView, path, newPath, item,
item.getModifiedTime().getLongValue());
if(null != renameEventItem) {
if (verbose) {
Log.logf("Renamed %s -> %s at %s",
path, newPath,
renameEventItem.getModifiedTime());
}
deleteInfo = new CommitInformation(renameEventItem.getModifiedTime().createDate(),
renameEventItem.getModifiedBy(),
"",
path);
} else {
// if it isn't a rename, must be a move operation.
if (verbose) {
Log.logf("No rename event found: %s -> %s something has moved", path, newPath);
}
declareEarlierCommitAsMoved(item, newPath);
// Not sure how this happens, but fill in with the
// only information we have: the last view time
// and the last person to modify the item.
deleteInfo = new CommitInformation(earliestTime, item.getModifiedBy(), "", path);
}
deleteInfo.setFileDelete(true);
ith.remove();
// Cause old file to be deleted..
currentCommitList.put(deleteInfo, item);
// Replace the existing entries for item if they have an earlier timestamp.
CommitInformation info = new CommitInformation(deleteInfo.getCommitDate(), deleteInfo.getUid(), "", newPath);
replaceEarlierCommitInfo(info, item, root);
}
}
} else {
Log.log("Never seen the file " + path + " in " + head);
}
}
if (deletedpaths.size() > 0) {
ItemList items = new ItemList();
for (int i = 0; i < deletedpaths.size(); i++) {
items.addItem(deletedpaths.get(i).getSecond());
}
PropertyNames propNames = currentView.getPropertyNames();
String[] populateProps = new String[] {
propNames.FILE_NAME,
PropertyNames.ITEM_DELETED_TIME,
PropertyNames.ITEM_DELETED_USER_ID,
};
try {
items.populateNow(populateProps);
} catch (com.starbase.starteam.NoSuchPropertyException e) {
Log.log("Could not populate the deleted files information");
}
for (int i = 0; i < deletedpaths.size(); i++) {
File item = deletedpaths.get(i).getSecond();
CommitInformation info = new CommitInformation(item.getDeletedTime().createDate(),
item.getDeletedUserID(),
"",
deletedpaths.get(i).getFirst());
if (verbose) {
Log.logf("Deleting %s at %d", deletedpaths.get(i).getFirst(), item.getDeletedTime().getLongValue());
}
info.setFileDelete(true);
// Deleted files won't have entries, so add one here to make the delete end up in a commit.
currentCommitList.put(info, item);
}
}
}
/**
* Find a earlier commit done that match with the given item and path to
* declare it as an unexpected move operation
*
* @param item
* The file that was detected as moved somewhere else
* @param newPath
* The path to check for
*/
private void declareEarlierCommitAsMoved(File item, String newPath) {
CommitInformation replacement = null;
File originalValue = null;
for (Iterator<Map.Entry<CommitInformation, File>> it = currentCommitList.entrySet().iterator(); it.hasNext();) {
Entry<CommitInformation, File> entry = it.next();
if (entry.getKey().getPath().equals(newPath) && item.getObjectID() == entry.getValue().getObjectID()) {
originalValue = entry.getValue();
// Time need to match with the delete instruction to be combined
// together
replacement = new CommitInformation(earliestTime, entry.getKey().getUid(), "Unexpected Move",
newPath);
it.remove();
break;
}
}
if (replacement != null) {
currentCommitList.put(replacement, originalValue);
}
}
/**
* Remove duplicate commit information from the current commit list in such
* preventing sending too much information to git and requiring too much
* information to Starteam.
*
* @param info
* commit that is replacing
* @param file
* The file which is being targeted
* @param root
* The root folder on which the importation is based on
*/
private void replaceEarlierCommitInfo(CommitInformation info, File file, Folder root) {
String path = pathname(file, root);
// TODO: a better data structure for fileList would make this more efficient.
for(Iterator<Map.Entry<CommitInformation, File>> ith = currentCommitList.entrySet().iterator(); ith.hasNext(); ) {
CommitInformation info2 = ith.next().getKey();
if (path.equals(info2.getPath()) && info2.getCommitDate().before(info.getCommitDate())) {
ith.remove();
return;
}
}
}
/**
* Correct the comment based on a set of basic rule against the file. If no
* comment are set into the file modification, a generic message will be set.
* "Modification without comments"
*
* @param historyFile
* Starteam history file on which the comment be generated.
* @return a corrected and trimmed comment
*/
protected String correctedComment(File historyFile) {
String comment = historyFile.getComment().trim();
if(0 == comment.length()) { // if the file doesn't have comment
if(1 == historyFile.getContentVersion()) {
comment = historyFile.getDescription().trim();
}
if (0 == comment.length()) { // Still has no comment...
comment = "Modification without comments";
}
} else if(comment.matches("Merge from .*?, Revision .*")) {
comment = "Merge from unknown branch";
}
return comment;
}
/**
* Create a commit information based on the path and history file
*
* @param path
* The target path in git repository
* @param fileToCommit
* The file the commit is based on
* @param iterationCounter
* Counter helper to correct the commit date forward 1 second based
* on the last filePopulation pass
*/
protected void createCommitInformation(String path, File fileToCommit, int iterationCounter) {
String comment = correctedComment(fileToCommit);
// This is a patchup time to prevent commit jumping up in time between view labels
Date authorDate = new java.util.Date(fileToCommit.getModifiedTime().getLongValue());
long timeOfCommit = authorDate.getTime();
if (earliestTime != null && earliestTime.getTime() >= timeOfCommit) {
// add offset with last commit to keep order. Based on the last commit
// from the previous pass + 1 second by counter
long newTime = earliestTime.getTime() + (1000 * iterationCounter);
if (verbose) {
Log.logf("Changing commit time of %s from %d to %d", path, timeOfCommit, newTime);
}
timeOfCommit = newTime;
}
Date commitDate = new java.util.Date(timeOfCommit);
CommitInformation info = new CommitInformation(commitDate, fileToCommit.getModifiedBy(), comment, path);
info.setAuthorDate(authorDate);
if (verbose) {
Log.log("Discovered commit <" + info + ">");
}
currentCommitList.put(info, fileToCommit);
}
@Override
public void setInitialPathList(Set<String> initialPaths) {
lastFiles.addAll(initialPaths);
}
@Override
public NavigableMap<CommitInformation, File> getListOfCommit() {
return currentCommitList;
}
@Override
public Set<String> pathToDelete() {
return deletedFiles;
}
@Override
public void setRepositoryHelper(RepositoryHelper helper) {
this.helper = helper;
}
@Override
public void setLastCommitTime(Date earliestTime) {
this.earliestTime = earliestTime;
if (verbose) {
Log.log("Set earliest commit to do at " + earliestTime);
}
}
@Override
public void setVerboseLogging(boolean verbose) {
this.verbose = verbose;
}
@Override
public List<String> getLastFiles() {
ArrayList<String> ret = new ArrayList<String>();
ret.addAll(lastFiles);
return ret;
}
@Override
public void setCurrentLabel(Label current) {
// The base population strategy isn't interested by this added information
}
@Override
public boolean isTagRequired() {
// Tag are always welcome with the base strategy
return true;
}
}