/********************************************************************************
* CruiseControl, a Continuous Integration Toolkit
* Copyright (c) 2001-2003, ThoughtWorks, Inc.
* 651 W Washington Ave. Suite 600
* Chicago, IL 60661 USA
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* + Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* + Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* + Neither the name of ThoughtWorks, Inc., CruiseControl, nor the
* names of its contributors may be used to endorse or promote
* products derived from this software without specific prior
* written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
********************************************************************************/
package net.sourceforge.cruisecontrol.sourcecontrols;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Hashtable;
import java.util.List;
import java.util.StringTokenizer;
import net.sourceforge.cruisecontrol.CruiseControlException;
import net.sourceforge.cruisecontrol.Modification;
import net.sourceforge.cruisecontrol.SourceControl;
import net.sourceforge.cruisecontrol.util.Commandline;
import net.sourceforge.cruisecontrol.util.DateUtil;
import net.sourceforge.cruisecontrol.util.OSEnvironment;
import net.sourceforge.cruisecontrol.util.StreamPumper;
import net.sourceforge.cruisecontrol.util.ValidationHelper;
import org.apache.log4j.Logger;
/**
* This class implements the SourceControlElement methods for a CVS repository.
* The call to CVS is assumed to work without any setup. This implies that if
* the authentication type is pserver the call to cvs login should be done
* prior to calling this class.
* <p/>
* There are also differing CVS client/server implementations (e.g. the <i>official</i>
* CVS and the CVSNT fork).
* <p/>
* Note that the log formats of the official CVS have changed starting from version 1.12.9.
* This class currently knows of 2 different outputs referred to as the 'old'
* and the 'new' output formats.
*
* @author <a href="mailto:pj@thoughtworks.com">Paul Julius</a>
* @author Robert Watkins
* @author Frederic Lavigne
* @author <a href="mailto:jcyip@thoughtworks.com">Jason Yip</a>
* @author Marc Paquette
* @author <a href="mailto:johnny.cass@epiuse.com">Johnny Cass</a>
* @author <a href="mailto:m@loonsoft.com">McClain Looney</a>
*/
public class CVS implements SourceControl {
/**
* name of the official cvs as returned as part of the 'cvs version' command output
*/
static final String OFFICIAL_CVS_NAME = "CVS";
static final Version DEFAULT_CVS_SERVER_VERSION = new Version(OFFICIAL_CVS_NAME, "1.11");
/**
* Represents the version of a CVS client or server
*/
static class Version {
private final String cvsName;
private final String cvsVersion;
public Version(String name, String version) {
if (name == null) {
throw new IllegalArgumentException("name can't be null");
}
if (version == null) {
throw new IllegalArgumentException("version can't be null");
}
this.cvsName = name;
this.cvsVersion = version;
}
public String getCvsName() {
return cvsName;
}
public String getCvsVersion() {
return cvsVersion;
}
public boolean equals(Object o) {
if (this == o) {
return true;
} else if (!(o instanceof Version)) {
return false;
}
final Version version = (Version) o;
if (!cvsName.equals(version.cvsName)) {
return false;
} else if (!cvsVersion.equals(version.cvsVersion)) {
return false;
}
return true;
}
public int hashCode() {
int result;
result = cvsName.hashCode();
result = 29 * result + cvsVersion.hashCode();
return result;
}
public String toString() {
return cvsName + " " + cvsVersion;
}
}
private Hashtable properties = new Hashtable();
private String property;
private String propertyOnDelete;
/**
* CVS allows for mapping user names to email addresses.
* If CVSROOT/users exists, it's contents will be parsed and stored in this
* hashtable.
*/
private Hashtable mailAliases;
/**
* The caller can provide the CVSROOT to use when calling CVS, or
* the CVSROOT environment variable will be used.
*/
private String cvsroot;
/**
* The caller must indicate where the local copy of the repository
* exists.
*/
private String local;
/**
* The CVS tag we are dealing with.
*/
private String tag;
/**
* The CVS module we are dealing with.
*/
private String module;
/**
* The version of the cvs server
*/
private Version cvsServerVersion;
/**
* enable logging for this class
*/
private static Logger log = Logger.getLogger(CVS.class);
/**
* This line delimits seperate files in the CVS log information.
*/
private static final String CVS_FILE_DELIM =
"=============================================================================";
/**
* This is the keyword that precedes the name of the RCS filename in the CVS
* log information.
*/
private static final String CVS_RCSFILE_LINE = "RCS file: ";
/**
* This is the keyword that precedes the name of the working filename in the
* CVS log information.
*/
private static final String CVS_WORKINGFILE_LINE = "Working file: ";
/**
* This line delimits the different revisions of a file in the CVS log
* information.
*/
private static final String CVS_REVISION_DELIM = "----------------------------";
/**
* This is the keyword that precedes the timestamp of a file revision in the
* CVS log information.
*/
private static final String CVS_REVISION_DATE = "date:";
/**
* This is the name of the tip of the main branch, which needs special handling with
* the log entry parser
*/
private static final String CVS_HEAD_TAG = "HEAD";
/**
* This is the keyword that tells us when we have reaced the ned of the
* header as found in the CVS log information.
*/
private static final String CVS_DESCRIPTION = "description:";
/**
* This is a state keyword which indicates that a revision to a file was not
* relevant to the current branch, or the revision consisted of a deletion
* of the file (removal from branch..).
*/
private static final String CVS_REVISION_DEAD = "dead";
/**
* System dependent new line seperator.
*/
private static final String NEW_LINE = System.getProperty("line.separator");
/**
* This is the date format returned in the log information from CVS.
*/
static final SimpleDateFormat LOGDATE = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss z");
/**
* Sets the CVSROOT for all calls to CVS.
*
* @param cvsroot CVSROOT to use.
*/
public void setCvsRoot(String cvsroot) {
this.cvsroot = cvsroot;
}
/**
* Sets the local working copy to use when making calls to CVS.
*
* @param local String indicating the relative or absolute path to the local
* working copy of the module of which to find the log history.
*/
public void setLocalWorkingCopy(String local) {
this.local = local;
}
/**
* Set the cvs tag. Note this should work with names, numbers, and anything
* else you can put on log -rTAG
*
* @param tag the cvs tag
*/
public void setTag(String tag) {
this.tag = tag;
}
/**
* Set the cvs module name. Note that this is only used when
* localworkingcopy is not set.
*
* @param module the cvs module
*/
public void setModule(String module) {
this.module = module;
}
public void setProperty(String property) {
this.property = property;
}
public void setPropertyOnDelete(String propertyOnDelete) {
this.propertyOnDelete = propertyOnDelete;
}
protected Version getCvsServerVersion() {
if (cvsServerVersion == null) {
Commandline commandLine = getCommandline();
commandLine.setExecutable("cvs");
if (cvsroot != null) {
commandLine.createArgument().setValue("-d");
commandLine.createArgument().setValue(cvsroot);
}
commandLine.createArgument().setLine("version");
Process p = null;
try {
if (local != null) {
commandLine.setWorkingDirectory(local);
}
p = commandLine.execute();
logErrorStream(p);
InputStream is = p.getInputStream();
BufferedReader in = new BufferedReader(new InputStreamReader(is));
cvsServerVersion = extractCVSServerVersionFromCVSVersionCommandOutput(in);
log.debug("cvs server version: " + cvsServerVersion);
p.waitFor();
p.getInputStream().close();
p.getOutputStream().close();
p.getErrorStream().close();
} catch (IOException e) {
log.error("Failed reading cvs server version", e);
} catch (CruiseControlException e) {
log.error("Failed reading cvs server version", e);
} catch (InterruptedException e) {
log.error("Failed reading cvs server version", e);
}
if (p == null || p.exitValue() != 0 || cvsServerVersion == null) {
if (p == null) {
log.debug("Process p was null in CVS.getCvsServerVersion()");
} else {
log.debug("Process exit value = " + p.exitValue());
}
cvsServerVersion = DEFAULT_CVS_SERVER_VERSION;
log.warn("problem getting cvs server version; using " + cvsServerVersion);
}
}
return cvsServerVersion;
}
/**
* This method retrieves the cvs server version from the specified output.
* The line it parses will have the following format:
* <pre>
* Server: Concurrent Versions System (CVS) 1.11.16 (client/server)
* </pre>
*
*
* @param in
* @return the version of null if the version couldn't be extracted
* @throws IOException
*/
private Version extractCVSServerVersionFromCVSVersionCommandOutput(BufferedReader in) throws IOException {
String line = in.readLine();
if (line == null) {
return null;
}
if (line.startsWith("Client:")) {
line = in.readLine();
if (line == null) {
return null;
}
if (!line.startsWith("Server:")) {
log.warn("Warning expected a line starting with \"Server:\" but got " + line);
// we try anyway
}
}
log.debug("server version line: " + line);
int nameBegin = line.indexOf(" (");
int nameEnd = line.indexOf(") ", nameBegin);
final String name;
final String version;
if (nameBegin == -1 || nameEnd < nameBegin || nameBegin + 2 >= line.length()) {
log.warn("cvs server version name couldn't be parsed from " + line);
return null;
}
name = line.substring(nameBegin + 2, nameEnd);
int verEnd = line.indexOf(" ", nameEnd + 2);
if (verEnd < nameEnd + 2) {
log.warn("cvs server version number couldn't be parsed from " + line);
return null;
}
version = line.substring(nameEnd + 2, verEnd);
return new Version(name, version);
}
public boolean isCvsNewOutputFormat() {
Version version = getCvsServerVersion();
if (OFFICIAL_CVS_NAME.equals(version.getCvsName())) {
String csv = version.getCvsVersion();
StringTokenizer st = new StringTokenizer(csv, ".");
try {
st.nextToken();
int subversion = Integer.parseInt(st.nextToken());
if (subversion > 11) {
if (subversion == 12) {
if (Integer.parseInt(st.nextToken()) < 9) {
return false;
}
}
return true;
}
} catch (Throwable e) {
log.warn("problem identifying cvs server. Assuming output is of 'old' type");
}
}
return false;
}
public Hashtable getProperties() {
return properties;
}
/** for mocking **/
protected OSEnvironment getOSEnvironment() {
return new OSEnvironment();
}
public void validate() throws CruiseControlException {
ValidationHelper.assertFalse(local == null && (cvsroot == null || module == null),
"must specify either 'localWorkingCopy' or 'cvsroot' and 'module' on CVS");
ValidationHelper.assertFalse(local != null && (cvsroot != null || module != null),
"if 'localWorkingCopy' is specified then cvsroot and module are not allowed on CVS");
ValidationHelper.assertFalse(local != null && !new File(local).exists(),
"Local working copy \"" + local + "\" does not exist!");
}
/**
* Returns a List of Modifications detailing all the changes between the
* last build and the latest revision at the repository
*
* @param lastBuild last build time
* @return maybe empty, never null.
*/
public List getModifications(Date lastBuild, Date now) {
mailAliases = getMailAliases();
List mods = null;
try {
mods = execHistoryCommand(buildHistoryCommand(lastBuild, now));
} catch (Exception e) {
log.error("Log command failed to execute succesfully", e);
}
if (mods == null) {
return new ArrayList();
}
return mods;
}
/**
* Get CVS's idea of user/address mapping. Only runs once per class
* instance. Won't run if the mailAlias was already set.
*
* @return a Hashtable containing the mapping defined in CVSROOT/users.
* If CVSROOT/users doesn't exist, an empty Hashtable is returned.
*/
private Hashtable getMailAliases() {
if (mailAliases == null) {
mailAliases = new Hashtable();
Commandline commandLine = getCommandline();
commandLine.setExecutable("cvs");
if (cvsroot != null) {
commandLine.createArgument().setValue("-d");
commandLine.createArgument().setValue(cvsroot);
}
commandLine.createArgument().setLine("-q co -p CVSROOT/users");
Process p = null;
try {
if (local != null) {
commandLine.setWorkingDirectory(local);
}
p = commandLine.execute();
logErrorStream(p);
InputStream is = p.getInputStream();
BufferedReader in = new BufferedReader(new InputStreamReader(is));
String line;
while ((line = in.readLine()) != null) {
addAliasToMap(line);
}
p.waitFor();
p.getInputStream().close();
p.getOutputStream().close();
p.getErrorStream().close();
} catch (Exception e) {
log.error("Failed reading mail aliases", e);
}
if (p == null || p.exitValue() != 0) {
if (p == null) {
log.debug("Process p was null in CVS.getMailAliases()");
} else {
log.debug("Process exit value = " + p.exitValue());
}
log.warn("problem getting CVSROOT/users; using empty email map");
mailAliases = new Hashtable();
}
}
return mailAliases;
}
void addAliasToMap(String line) {
log.debug("Mapping " + line);
int colon = line.indexOf(':');
if (colon >= 0) {
String user = line.substring(0, colon);
String address = line.substring(colon + 1);
mailAliases.put(user, address);
}
}
/**
* @param lastBuildTime
* @param checkTime
* @return CommandLine for "cvs -d CVSROOT -q log -N -dlastbuildtime<checktime "
*/
public Commandline buildHistoryCommand(Date lastBuildTime, Date checkTime) throws CruiseControlException {
Commandline commandLine = getCommandline();
commandLine.setExecutable("cvs");
if (cvsroot != null) {
commandLine.createArgument().setValue("-d");
commandLine.createArgument().setValue(cvsroot);
}
commandLine.createArgument().setValue("-q");
if (local != null) {
commandLine.setWorkingDirectory(local);
commandLine.createArgument().setValue("log");
} else {
commandLine.createArgument().setValue("rlog");
}
boolean useHead = tag == null || tag.equals(CVS_HEAD_TAG) || tag.equals("");
if (useHead) {
commandLine.createArgument().setValue("-N");
}
String dateRange = formatCVSDate(lastBuildTime) + "<" + formatCVSDate(checkTime);
commandLine.createArgument().setValue("-d" + dateRange);
if (!useHead) {
// add -b and -rTAG to list changes relative to the current branch,
// not relative to the default branch, which is HEAD
// note: -r cannot have a space between itself and the tag spec.
commandLine.createArgument().setValue("-r" + tag);
} else {
// This is used to include the head only if a Tag is not specified.
commandLine.createArgument().setValue("-b");
}
if (local == null) {
commandLine.createArgument().setValue(module);
}
return commandLine;
}
// factory method for mock...
protected Commandline getCommandline() {
return new Commandline();
}
static String formatCVSDate(Date date) {
return DateUtil.formatCVSDate(date);
}
/**
* Parses the input stream, which should be from the cvs log command. This
* method will format the data found in the input stream into a List of
* Modification instances.
*
* @param input InputStream to get log data from.
* @return List of Modification elements, maybe empty never null.
* @throws IOException
*/
protected List parseStream(InputStream input) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(input));
// Read to the first RCS file name. The first entry in the log
// information will begin with this line. A CVS_FILE_DELIMITER is NOT
// present. If no RCS file lines are found then there is nothing to do.
String line = readToNotPast(reader, CVS_RCSFILE_LINE, null);
ArrayList mods = new ArrayList();
while (line != null) {
// Parse the single file entry, which may include several
// modifications.
List returnList = parseEntry(reader, line);
//Add all the modifications to the local list.
mods.addAll(returnList);
// Read to the next RCS file line. The CVS_FILE_DELIMITER may have
// been consumed by the parseEntry method, so we cannot read to it.
line = readToNotPast(reader, CVS_RCSFILE_LINE, null);
}
return mods;
}
private void getRidOfLeftoverData(InputStream stream) {
StreamPumper outPumper = new StreamPumper(stream, (PrintWriter) null);
new Thread(outPumper).start();
}
private List execHistoryCommand(Commandline command) throws Exception {
Process p = command.execute();
logErrorStream(p);
InputStream cvsLogStream = p.getInputStream();
List mods = parseStream(cvsLogStream);
getRidOfLeftoverData(cvsLogStream);
p.waitFor();
p.getInputStream().close();
p.getOutputStream().close();
p.getErrorStream().close();
return mods;
}
protected void setMailAliases(Hashtable mailAliases) {
this.mailAliases = mailAliases;
}
private void logErrorStream(Process p) {
StreamPumper errorPumper =
new StreamPumper(p.getErrorStream(), new PrintWriter(System.err, true));
new Thread(errorPumper).start();
}
//(PENDING) Extract CVSEntryParser class
/**
* Parses a single file entry from the reader. This entry may contain zero or
* more revisions. This method may consume the next CVS_FILE_DELIMITER line
* from the reader, but no further.
*
* When the log is related to a non branch tag, only the last modification for each file will be listed.
*
* @param reader Reader to parse data from.
* @return modifications found in this entry; maybe empty, never null.
* @throws IOException
*/
private List parseEntry(BufferedReader reader, String rcsLine) throws IOException {
ArrayList mods = new ArrayList();
String nextLine = "";
// Read to the working file name line to get the filename.
// If working file name line isn't found we'll extract is from the RCS file line
String workingFileName;
if (module != null) {
workingFileName = rcsLine.substring(rcsLine.indexOf(module) + module.length(), rcsLine.length() - 2);
} else {
String workingFileLine = readToNotPast(reader, CVS_WORKINGFILE_LINE, null);
workingFileName = workingFileLine.substring(CVS_WORKINGFILE_LINE.length());
}
String branchRevisionName = parseBranchRevisionName(reader, tag);
boolean newCVSVersion = isCvsNewOutputFormat();
while (nextLine != null && !nextLine.startsWith(CVS_FILE_DELIM)) {
nextLine = readToNotPast(reader, "revision", CVS_FILE_DELIM);
if (nextLine == null) {
//No more revisions for this file.
break;
}
nextLine.length();
StringTokenizer tokens = new StringTokenizer(nextLine, " ");
tokens.nextToken();
String revision = tokens.nextToken();
if (tag != null && !tag.equals(CVS_HEAD_TAG)) {
if (!revision.equals(branchRevisionName)) {
// Indeed this is a branch, not just a regular tag
String itsBranchRevisionName = revision.substring(0, revision.lastIndexOf('.'));
if (!itsBranchRevisionName.equals(branchRevisionName)) {
break;
}
}
}
// Read to the revision date. It is ASSUMED that each revision
// section will include this date information line.
nextLine = readToNotPast(reader, CVS_REVISION_DATE, CVS_FILE_DELIM);
if (nextLine == null) {
break;
}
tokens = new StringTokenizer(nextLine, " \t\n\r\f;");
// First token is the keyword for date, then the next two should be
// the date and time stamps.
tokens.nextToken();
String dateStamp = tokens.nextToken();
String timeStamp = tokens.nextToken();
// skips the +0000 part of new format
if (newCVSVersion) {
tokens.nextToken();
}
// The next token should be the author keyword, then the author name.
tokens.nextToken();
String authorName = tokens.nextToken();
// The next token should be the state keyword, then the state name.
tokens.nextToken();
String stateKeyword = tokens.nextToken();
// if no lines keyword then file is added
boolean isAdded = !tokens.hasMoreTokens();
// All the text from now to the next revision delimiter or working
// file delimiter constitutes the messsage.
String message = "";
nextLine = reader.readLine();
boolean multiLine = false;
while (nextLine != null
&& !nextLine.startsWith(CVS_FILE_DELIM)
&& !nextLine.startsWith(CVS_REVISION_DELIM)) {
if (multiLine) {
message += NEW_LINE;
} else {
multiLine = true;
}
message += nextLine;
//Go to the next line.
nextLine = reader.readLine();
}
Modification nextModification = new Modification("cvs");
nextModification.revision = revision;
int lastSlashIndex = workingFileName.lastIndexOf("/");
String fileName, folderName = null;
fileName = workingFileName.substring(lastSlashIndex + 1);
if (lastSlashIndex != -1) {
folderName = workingFileName.substring(0, lastSlashIndex);
}
Modification.ModifiedFile modfile = nextModification.createModifiedFile(fileName, folderName);
modfile.revision = nextModification.revision;
try {
if (newCVSVersion) {
nextModification.modifiedTime = DateUtil.parseCVSDate(
dateStamp + " " + timeStamp + " GMT");
} else {
nextModification.modifiedTime = LOGDATE.parse(dateStamp + " " + timeStamp + " GMT");
}
} catch (ParseException pe) {
log.error("Error parsing cvs log for date and time", pe);
return null;
}
nextModification.userName = authorName;
String address = (String) mailAliases.get(authorName);
if (address != null) {
nextModification.emailAddress = address;
}
nextModification.comment = (message != null ? message : "");
if (stateKeyword.equalsIgnoreCase(CVS_REVISION_DEAD)
&& message.indexOf("was initially added on branch") != -1) {
log.debug("skipping branch addition activity for " + nextModification);
//this prevents additions to a branch from showing up as action "deleted" from head
continue;
}
if (stateKeyword.equalsIgnoreCase(CVS_REVISION_DEAD)) {
modfile.action = "deleted";
if (propertyOnDelete != null) {
properties.put(propertyOnDelete, "true");
}
} else if (isAdded) {
modfile.action = "added";
} else {
modfile.action = "modified";
}
if (property != null) {
properties.put(property, "true");
}
mods.add(nextModification);
}
return mods;
}
/**
* Find the CVS branch revision name, when the tag is not HEAD
* The reader will consume all lines up to the next description.
*
* @param reader the reader
* @param tag may be null
* @return the branch revision name, or <code>null</code> if not applicable or none was found.
* @throws IOException
*/
private static String parseBranchRevisionName(BufferedReader reader, final String tag) throws IOException {
String branchRevisionName = null;
if (tag != null && !tag.equals(CVS_HEAD_TAG)) {
// Look for the revision of the form "tag: *.(0.)y ". this doesn't work for HEAD
// get line with branch revision on it.
String branchRevisionLine = readToNotPast(reader, "\t" + tag + ": ", CVS_DESCRIPTION);
if (branchRevisionLine != null) {
// Look for the revision of the form "tag: *.(0.)y ", return "*.y"
branchRevisionName = branchRevisionLine.substring(tag.length() + 3);
if (branchRevisionName.charAt(branchRevisionName.lastIndexOf(".") - 1) == '0') {
branchRevisionName =
branchRevisionName.substring(0, branchRevisionName.lastIndexOf(".") - 2)
+ branchRevisionName.substring(branchRevisionName.lastIndexOf("."));
}
}
}
return branchRevisionName;
}
/**
* This method will consume lines from the reader up to the line that begins
* with the String specified but not past a line that begins with the
* notPast String. If the line that begins with the beginsWith String is
* found then it will be returned. Otherwise null is returned.
*
* @param reader Reader to read lines from.
* @param beginsWith String to match to the beginning of a line.
* @param notPast String which indicates that lines should stop being consumed,
* even if the begins with match has not been found. Pass null to this
* method to ignore this string.
* @return String that begin as indicated, or null if none matched to the end
* of the reader or the notPast line was found.
* @throws IOException
*/
private static String readToNotPast(BufferedReader reader, String beginsWith, String notPast)
throws IOException {
boolean checkingNotPast = notPast != null;
String nextLine = reader.readLine();
while (nextLine != null && !nextLine.startsWith(beginsWith)) {
if (checkingNotPast && nextLine.startsWith(notPast)) {
return null;
}
nextLine = reader.readLine();
}
return nextLine;
}
}