// Copyright 2004-2014 Jim Voris // // 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 com.qumasoft.qvcslib; import java.io.ByteArrayInputStream; import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.Iterator; import java.util.Map; import java.util.TreeMap; import java.util.logging.Level; import java.util.logging.Logger; /** * This class implements a file level merge. It requires 3 input files, and one output file. The 3 input files consist of a common ancestor file, and two descendents of that common * ancestor file. Basically, it diffs descendent 1 against the common ancestor, then diffs descendent 2 against the common ancestor, and then merges the resulting edit scripts * produced by those two diffs. It then applies the merged edit script against the common ancestor to produce a merged result. If there are no collisions between the two diffs, * then the resulting merged output will contain the merged edits of the two descendents. If there are collisions, then the merge will fail. * * @author Jim Voris */ public final class FileMerge implements QVCSOperation { // Create our logger object private static final Logger LOGGER = Logger.getLogger("com.qumasoft.qvcslib"); private String[] args; private String baseFileName; private String firstDescendentName; private String secondDescendentName; private String outputFileName; private Map<String, EditInfo> editScript = null; private long insertedBytesCount = 0L; private static final int ARGUMENT_COUNT = 4; private static final int BASE_FILENAME_ARG_INDEX = 0; private static final int FIRST_DESCENDENT_FILENAME_ARG_INDEX = 1; private static final int SECOND_DESCENDENT_FILENAME_ARG_INDEX = 2; private static final int OUTPUT_FILENAME_ARG_INDEX = 3; private static final int COMPARE_FILES_ARG_COUNT = 3; /** * Default constructor. */ public FileMerge() { } /** * File merge constructor with arguments. * @param arguments a String[] with the arguments: base file name; first descendent file name; second descendent file name; output file name. */ public FileMerge(final String[] arguments) { this.args = arguments; } @Override public boolean execute(String[] arguments) throws QVCSOperationException { this.args = arguments; copyArguments(); return execute(); } private void copyArguments() { baseFileName = args[BASE_FILENAME_ARG_INDEX]; firstDescendentName = args[FIRST_DESCENDENT_FILENAME_ARG_INDEX]; secondDescendentName = args[SECOND_DESCENDENT_FILENAME_ARG_INDEX]; outputFileName = args[OUTPUT_FILENAME_ARG_INDEX]; } /** * Merge the two descendent files of the base file into a single output file. * * @param baseName the common ancestor of the two descendent files. * @param firstDescFileName the filename of the first descendent file. * @param secondDescFileName the filename of the second descendent file. * @param outputName the filename of the output file that we create. * @return true if the merge succeeds. false if the merge fails. * @throws com.qumasoft.qvcslib.QVCSOperationException for validation or other QVCS related problems. */ public boolean mergeFiles(final String baseName, final String firstDescFileName, final String secondDescFileName, final String outputName) throws QVCSOperationException { String[] localArgs = new String[ARGUMENT_COUNT]; localArgs[BASE_FILENAME_ARG_INDEX] = baseName; localArgs[FIRST_DESCENDENT_FILENAME_ARG_INDEX] = firstDescFileName; localArgs[SECOND_DESCENDENT_FILENAME_ARG_INDEX] = secondDescFileName; localArgs[OUTPUT_FILENAME_ARG_INDEX] = outputName; return execute(localArgs); } @Override public boolean execute() throws QVCSOperationException { boolean retVal; try { // Make sure we have the arguments that we need. validateArguments(); // Make a new empty merged edit script. editScript = new TreeMap<>(); // Compare the first descendent to the base file. String[] firstCompareArgs = new String[COMPARE_FILES_ARG_COUNT]; firstCompareArgs[0] = baseFileName; firstCompareArgs[1] = firstDescendentName; File firstCompareOutputTempFile = File.createTempFile("QVCS", ".tmp"); firstCompareOutputTempFile.deleteOnExit(); firstCompareArgs[2] = firstCompareOutputTempFile.getCanonicalPath(); LOGGER.log(Level.INFO, "Comparing [" + baseFileName + "] to [" + firstDescendentName + "]"); CompareFilesWithApacheDiff firstCompareFilesOperator = new CompareFilesWithApacheDiff(firstCompareArgs); if (!firstCompareFilesOperator.execute()) { throw new QVCSOperationException("Failed to compare [" + baseFileName + "] to file revision [" + firstDescendentName + "]"); } // Compare the second descendent to the base file. String[] secondCompareArgs = new String[COMPARE_FILES_ARG_COUNT]; secondCompareArgs[0] = baseFileName; secondCompareArgs[1] = secondDescendentName; File secondCompareOutputTempFile = File.createTempFile("QVCS", ".tmp"); secondCompareOutputTempFile.deleteOnExit(); secondCompareArgs[2] = secondCompareOutputTempFile.getCanonicalPath(); LOGGER.log(Level.INFO, "Comparing [" + baseFileName + "] to [" + secondDescendentName + "]"); CompareFilesWithApacheDiff secondCompareFilesOperator = new CompareFilesWithApacheDiff(secondCompareArgs); if (!secondCompareFilesOperator.execute()) { throw new QVCSOperationException("Failed to compare [" + baseFileName + "] to file revision [" + secondDescendentName + "]"); } // Merge the two edit scripts into a single merged edit script (contained in // the m_EditScript instance variable. mergeEditScripts(firstCompareOutputTempFile, secondCompareOutputTempFile); // Apply the edits. We'll get here if and only if there are no // overlaps. applyEdits(); retVal = true; } catch (IOException e) { LOGGER.log(Level.WARNING, Utility.expandStackTraceToString(e)); retVal = false; } return retVal; } /** * Walk the list of edits and apply them to the the base file. */ private void applyEdits() throws IOException, QVCSOperationException { FileInputStream fileInputStream = null; FileOutputStream fileOutputStream = null; File baseFile = new File(baseFileName); byte[] originalData = new byte[(int) baseFile.length()]; byte[] editedBuffer = new byte[(int) getInsertedBytesCount() + originalData.length]; // It can't be any bigger than this. int inIndex = 0; int outIndex = 0; int deletedBytesCount; int localInsertedBytesCount; int bytesTillChange = 0; EditInfo editInfo = null; try { fileInputStream = new FileInputStream(baseFile); fileInputStream.read(originalData); Iterator<EditInfo> it = editScript.values().iterator(); while (it.hasNext()) { editInfo = it.next(); bytesTillChange = (int) editInfo.getSeekPosition() - inIndex; System.arraycopy(originalData, inIndex, editedBuffer, outIndex, bytesTillChange); inIndex += bytesTillChange; outIndex += bytesTillChange; deletedBytesCount = (int) editInfo.getDeletedBytesCount(); localInsertedBytesCount = (int) editInfo.getInsertedBytesCount(); switch (editInfo.getEditType()) { case CompareFilesEditInformation.QVCS_EDIT_DELETE: /* * Delete input. * Just skip over deleted bytes. */ inIndex += deletedBytesCount; break; case CompareFilesEditInformation.QVCS_EDIT_INSERT: /* * Insert edit lines */ System.arraycopy(editInfo.getInsertedBytes(), 0, editedBuffer, outIndex, localInsertedBytesCount); outIndex += localInsertedBytesCount; break; case CompareFilesEditInformation.QVCS_EDIT_REPLACE: /* * Replace input line with edit line * First skip over the bytes to be replaced, then copy the replacing bytes from the edit file to the output file. */ inIndex += deletedBytesCount; System.arraycopy(editInfo.getInsertedBytes(), 0, editedBuffer, outIndex, localInsertedBytesCount); outIndex += localInsertedBytesCount; break; default: continue; } } // Copy the rest of the input "file" to the output "file". int remainingBytes = originalData.length - inIndex; if (remainingBytes > 0) { System.arraycopy(originalData, inIndex, editedBuffer, outIndex, remainingBytes); outIndex += remainingBytes; } // Write the result to the output file. File outputFile = new File(outputFileName); fileOutputStream = new FileOutputStream(outputFile); fileOutputStream.write(editedBuffer, 0, outIndex); } catch (IOException e) { LOGGER.log(Level.WARNING, Utility.expandStackTraceToString(e)); if (editInfo != null) { LOGGER.log(Level.WARNING, " editInfo.seekPosition: " + editInfo.getSeekPosition() + " originalData.length: " + originalData.length + " inIndex: " + inIndex + " editedBuffer.length: " + editedBuffer.length + " outIndex: " + outIndex + " bytesTillChange: " + bytesTillChange); } LOGGER.log(Level.WARNING, e.getLocalizedMessage()); throw new QVCSOperationException("Internal error!! Failed to apply edits in FileMerge."); } finally { try { if (fileInputStream != null) { fileInputStream.close(); } } catch (IOException e) { LOGGER.log(Level.WARNING, Utility.expandStackTraceToString(e)); } if (fileOutputStream != null) { fileOutputStream.close(); } } } private String getEditRecordType(FileMerge.EditInfo editInfo) { String editInfoType; switch (editInfo.getEditType()) { case CompareFilesEditInformation.QVCS_EDIT_DELETE: /* * Delete input */ editInfoType = "DELETE"; break; case CompareFilesEditInformation.QVCS_EDIT_INSERT: /* * Insert edit lines */ editInfoType = "INSERT"; break; case CompareFilesEditInformation.QVCS_EDIT_REPLACE: /* * Replace input line with edit line */ editInfoType = "REPLACE"; break; default: editInfoType = "UNKNOWN"; break; } return editInfoType; } private void mergeEditScripts(File firstCompareOutputTempFile, File secondCompareOutputTempFile) throws QVCSOperationException { addFileToEditScript(firstCompareOutputTempFile, 1); addFileToEditScript(secondCompareOutputTempFile, 2); // Check for overlap. long lastEditEndingPosition = 0L; EditInfo lastEditInfo = null; Iterator<EditInfo> it = editScript.values().iterator(); while (it.hasNext()) { EditInfo editInfo = it.next(); if (editInfo.getSeekPosition() <= lastEditEndingPosition) { String overlapMessage; if (lastEditInfo != null) { overlapMessage = "Overlap detected between " + getEditRecordType(lastEditInfo) + " edit record from file " + lastEditInfo.getFileIndex() + " at location " + lastEditInfo.getSeekPosition() + " with length " + lastEditInfo.getEditLength() + " and " + getEditRecordType(editInfo) + " edit record from file " + editInfo.getFileIndex() + " at location " + editInfo.getSeekPosition() + " with length " + editInfo.getEditLength(); } else { overlapMessage = "Overlap detected at " + editInfo.getFileIndex(); } throw new QVCSOperationException(overlapMessage); } lastEditEndingPosition = editInfo.getSeekPosition() + editInfo.getEditLength(); lastEditInfo = editInfo; insertedBytesCount += editInfo.getInsertedBytesCount(); } } private void addFileToEditScript(File editScriptToAdd, int fileIndex) throws QVCSOperationException { FileInputStream fileInputStream = null; try { byte[] fileData = new byte[(int) editScriptToAdd.length()]; fileInputStream = new FileInputStream(editScriptToAdd); fileInputStream.read(fileData); DataInputStream editStream = new DataInputStream(new ByteArrayInputStream(fileData)); CompareFilesEditInformation cfei = new CompareFilesEditInformation(); // Skip over the header bytes that are a prefix at the beginning of the // edit script. byte[] headerBytesToSkip = new byte[CompareFilesEditHeader.getEditHeaderSize()]; editStream.read(headerBytesToSkip); while (editStream.available() > 0) { byte[] insertBuffer = null; cfei.read(editStream); if ((cfei.getEditType() == CompareFilesEditInformation.QVCS_EDIT_INSERT) || (cfei.getEditType() == CompareFilesEditInformation.QVCS_EDIT_REPLACE)) { insertBuffer = new byte[(int) cfei.getInsertedBytesCount()]; editStream.read(insertBuffer); } EditInfo editInfo = new EditInfo(cfei.getSeekPosition(), fileIndex, cfei.getEditType(), cfei.getDeletedBytesCount(), cfei.getInsertedBytesCount(), insertBuffer); editScript.put(String.format("%015d,%d", cfei.getSeekPosition(), fileIndex), editInfo); } } catch (IOException e) { LOGGER.log(Level.WARNING, Utility.expandStackTraceToString(e)); throw new QVCSOperationException(e.getLocalizedMessage()); } finally { if (fileInputStream != null) { try { fileInputStream.close(); } catch (IOException e) { LOGGER.log(Level.WARNING, Utility.expandStackTraceToString(e)); throw new QVCSOperationException(e.getLocalizedMessage()); } } } } /** * Validate the arguments needed for a file merge. We need 4 arguments: <br> the base file name<br> the file name of the first descendent.<br> the file name of the 2nd * descendent.<br> the file name of the output file.... i.e. the file that will contained the merged result. </p> * * @throws com.qumasoft.operations.QVCSOperationException */ private void validateArguments() throws QVCSOperationException { if (args == null) { throw new QVCSOperationException("No arguments specified."); } else { if (args.length != ARGUMENT_COUNT) { throw new QVCSOperationException("You must specify 4 separate file names."); } else { baseFileName = args[BASE_FILENAME_ARG_INDEX]; firstDescendentName = args[FIRST_DESCENDENT_FILENAME_ARG_INDEX]; secondDescendentName = args[SECOND_DESCENDENT_FILENAME_ARG_INDEX]; outputFileName = args[OUTPUT_FILENAME_ARG_INDEX]; // Verify that each input file exists. checkThatFileExists(baseFileName, "Base file"); checkThatFileExists(firstDescendentName, "First descendent"); checkThatFileExists(secondDescendentName, "Second descendent"); } } } private void checkThatFileExists(final String fileName, final String fileUsedAs) throws QVCSOperationException { File file = new File(fileName); if (!file.exists()) { throw new QVCSOperationException(fileUsedAs + " '" + baseFileName + "' is missing."); } else { // File exists. Make sure we can read it... if (!file.canRead()) { throw new QVCSOperationException("Don't have rights to read " + fileUsedAs + " : '" + fileName + "'."); } } } private long getInsertedBytesCount() { return insertedBytesCount; } static class EditInfo { private final long seekPosition; /** * Track which file this edit info is associated with */ private final int fileIndex; private final short editType; private final long deletedBytesCount; private final long insertedBytesCount; private final byte[] insertedBytes; EditInfo(long seekPos, int fileIdx, int editTyp, long deletedBytesCnt, long insertedBytesCnt, byte[] insrtedBytes) { this.seekPosition = seekPos; this.fileIndex = fileIdx; this.editType = (short) editTyp; this.deletedBytesCount = deletedBytesCnt; this.insertedBytesCount = insertedBytesCnt; this.insertedBytes = insrtedBytes; } public long getSeekPosition() { return seekPosition; } /** * Return the file index -- i.e. which file this edit info instance is associated with. * * @return the file index for this instance. The value should be a 1 or a 2. */ public int getFileIndex() { return fileIndex; } public short getEditType() { return editType; } public long getDeletedBytesCount() { return deletedBytesCount; } public long getInsertedBytesCount() { return insertedBytesCount; } public byte[] getInsertedBytes() { return insertedBytes; } /** * Figure out how many bytes this edit record affects in base file... i.e. how far into the file from the current position will these changes occur. This is useful for * detecting overlap. * * @return the number of bytes that this edit will affect in the base file. * @throws com.qumasoft.operations.QVCSOperationException this should never get thrown. If it does, it is an internal error -- we are seeing an edit script that is corrupt. */ private long getEditLength() throws QVCSOperationException { long editLength = -1L; switch (editType) { case CompareFilesEditInformation.QVCS_EDIT_DELETE: editLength = getDeletedBytesCount(); break; case CompareFilesEditInformation.QVCS_EDIT_INSERT: editLength = getInsertedBytesCount(); break; case CompareFilesEditInformation.QVCS_EDIT_REPLACE: if (getDeletedBytesCount() > getInsertedBytesCount()) { editLength = getDeletedBytesCount(); } else { editLength = getInsertedBytesCount(); } break; default: throw new QVCSOperationException("Internal error. Unknown edit type in FileMerge.java"); } return editLength; } } }