changeset 413:7f27122011c3

Support and respect for symbolic links and executable flag, with /bin/ls backed implementation to discover these
author Artem Tikhomirov <tikhomirov.artem@gmail.com>
date Wed, 21 Mar 2012 20:40:28 +0100
parents d56ea1a2537a
children bb278ccf9866
files build.xml src/org/tmatesoft/hg/internal/Internals.java src/org/tmatesoft/hg/internal/ProcessExecHelper.java src/org/tmatesoft/hg/repo/HgManifest.java src/org/tmatesoft/hg/repo/HgWorkingCopyStatusCollector.java src/org/tmatesoft/hg/util/FileInfo.java src/org/tmatesoft/hg/util/FileIterator.java src/org/tmatesoft/hg/util/FileWalker.java src/org/tmatesoft/hg/util/RegularFileInfo.java src/org/tmatesoft/hg/util/RegularFileStats.java test/org/tmatesoft/hg/test/ExecHelper.java
diffstat 11 files changed, 509 insertions(+), 95 deletions(-) [+]
line wrap: on
line diff
--- a/build.xml	Thu Mar 15 16:51:46 2012 +0100
+++ b/build.xml	Wed Mar 21 20:40:28 2012 +0100
@@ -27,7 +27,7 @@
 
 	<property name="junit.jar" value="lib/junit-4.8.2.jar" />
 	<property name="ver.qualifier" value="" />
-	<property name="version.lib" value="0.8.0" />
+	<property name="version.lib" value="0.9.0" />
 	<property name="version.jar" value="${version.lib}${ver.qualifier}" />
 	<property name="compile-with-debug" value="yes"/>
 
--- a/src/org/tmatesoft/hg/internal/Internals.java	Thu Mar 15 16:51:46 2012 +0100
+++ b/src/org/tmatesoft/hg/internal/Internals.java	Wed Mar 21 20:40:28 2012 +0100
@@ -162,6 +162,29 @@
 	}
 	
 	/**
+	 * @param hint optional hint pointing to filesystem of interest (generally, it's possible to mount 
+	 * filesystems with different capabilities and repository's capabilities would depend on which fs it resides) 
+	 * @return <code>true</code> if executable files deserve tailored handling 
+	 */
+	public static boolean checkSupportsExecutables(File fsHint) {
+		// *.exe are not executables for Mercurial
+		return !runningOnWindows();
+	}
+
+	/**
+	 * @param hint optional hint pointing to filesystem of interest (generally, it's possible to mount 
+	 * filesystems with different capabilities and repository's capabilities would depend on which fs it resides) 
+	 * @return <code>true</code> if filesystem knows what symbolic links are 
+	 */
+	public static boolean checkSupportsSymlinks(File fsHint) {
+		// Windows supports soft symbolic links starting from Vista 
+		// However, as of Mercurial 2.1.1, no support for this functionality
+		// XXX perhaps, makes sense to override with a property a) to speed up when no links are in use b) investigate how this runs windows
+		return !runningOnWindows();
+	}
+
+	
+	/**
 	 * For Unix, returns installation root, which is the parent directory of the hg executable (or symlink) being run.
 	 * For Windows, it's Mercurial installation directory itself 
 	 * @param ctx 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/internal/ProcessExecHelper.java	Wed Mar 21 20:40:28 2012 +0100
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2012 TMate Software Ltd
+ *  
+ * This program 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; version 2 of the License.
+ *
+ * This program 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.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.internal;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.CharBuffer;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * Utility to run shell commands. Not thread-safe.
+ * Beware of memory overcommitment issue on Linux - suprocess get allocated virtual memory of parent process size
+ * @see http://developers.sun.com/solaris/articles/subprocess/subprocess.html
+ * 
+ * @author Artem Tikhomirov
+ * @author Tmate Software Ltd.
+ */
+public class ProcessExecHelper {
+	private File dir;
+	private int exitValue;
+	private ProcessBuilder pb;
+	
+	public ProcessExecHelper() {
+	}
+	
+	protected List<String> prepareCommand(List<String> cmd) {
+		return cmd;
+	}
+	
+	public CharSequence exec(String... command) throws IOException, InterruptedException {
+		return exec(Arrays.asList(command));
+	}
+
+	public CharSequence exec(List<String> command) throws IOException, InterruptedException {
+		List<String> cmd = prepareCommand(command);
+		if (pb == null) {
+			pb = new ProcessBuilder(cmd).directory(dir).redirectErrorStream(true);
+		} else {
+			pb.command(cmd); // dir and redirect are set
+		}
+		Process p = pb.start();
+		InputStreamReader stdOut = new InputStreamReader(p.getInputStream());
+		LinkedList<CharBuffer> l = new LinkedList<CharBuffer>();
+		int r = -1;
+		CharBuffer b = null;
+		do {
+			if (b == null || b.remaining() < b.capacity() / 3) {
+				b = CharBuffer.allocate(512);
+				l.add(b);
+			}
+			r = stdOut.read(b);
+		} while (r != -1);
+		int total = 0;
+		for (CharBuffer cb : l) {
+			total += cb.position();
+			cb.flip();
+		}
+		CharBuffer res = CharBuffer.allocate(total);
+		for (CharBuffer cb : l) {
+			res.put(cb);
+		}
+		res.flip();
+		p.waitFor();
+		exitValue = p.exitValue();
+		return res;
+	}
+	
+	public int exitValue() {
+		return exitValue;
+	}
+
+	public ProcessExecHelper cwd(File wd) {
+		dir = wd;
+		if (pb != null) {
+			pb.directory(dir);
+		}
+		return this;
+	}
+}
--- a/src/org/tmatesoft/hg/repo/HgManifest.java	Thu Mar 15 16:51:46 2012 +0100
+++ b/src/org/tmatesoft/hg/repo/HgManifest.java	Wed Mar 21 20:40:28 2012 +0100
@@ -54,7 +54,7 @@
 	private RevisionMapper revisionMap;
 	
 	public enum Flags {
-		Exec, Link;
+		Exec, Link; // FIXME REVISIT consider REGULAR instead of null
 		
 		static Flags parse(String flags) {
 			if ("x".equalsIgnoreCase(flags)) {
--- a/src/org/tmatesoft/hg/repo/HgWorkingCopyStatusCollector.java	Thu Mar 15 16:51:46 2012 +0100
+++ b/src/org/tmatesoft/hg/repo/HgWorkingCopyStatusCollector.java	Wed Mar 21 20:40:28 2012 +0100
@@ -37,6 +37,7 @@
 import org.tmatesoft.hg.internal.ByteArrayChannel;
 import org.tmatesoft.hg.internal.Experimental;
 import org.tmatesoft.hg.internal.FilterByteChannel;
+import org.tmatesoft.hg.internal.Internals;
 import org.tmatesoft.hg.internal.ManifestRevision;
 import org.tmatesoft.hg.internal.PathScope;
 import org.tmatesoft.hg.internal.Preview;
@@ -287,27 +288,37 @@
 			// either clean or modified
 			final boolean timestampEqual = f.lastModified() == r.modificationTime(), sizeEqual = r.size() == f.length();
 			if (timestampEqual && sizeEqual) {
-				inspector.clean(fname);
+				// if flags change (chmod -x), timestamp does not change
+				if (checkFlagsEqual(f, r.mode())) {
+					inspector.clean(fname);
+				} else {
+					inspector.modified(fname); // flags are not the same
+				}
 			} else if (!sizeEqual && r.size() >= 0) {
 				inspector.modified(fname);
 			} else {
+				// size is the same or unknown, and, perhaps, different timestamp
+				// check actual content to avoid false modified files
 				try {
-					// size is the same or unknown, and, perhaps, different timestamp
-					// check actual content to avoid false modified files
-					HgDataFile df = repo.getFileNode(fname);
-					if (!df.exists()) {
-						String msg = String.format("File %s known as normal in dirstate (%d, %d), doesn't exist at %s", fname, r.modificationTime(), r.size(), repo.getStoragePath(df));
-						throw new HgInvalidFileException(msg, null).setFileName(fname);
-					}
-					Nodeid rev = getDirstateParentManifest().nodeid(fname);
-					// rev might be null here if fname comes to dirstate as a result of a merge operation
-					// where one of the parents (first parent) had no fname file, but second parent had.
-					// E.g. fork revision 3, revision 4 gets .hgtags, few modifications and merge(3,12)
-					// see Issue 14 for details
-					if (rev == null || !areTheSame(f, df, rev)) {
-						inspector.modified(df.getPath());
+					if (!checkFlagsEqual(f, r.mode())) {
+						// flags modified, no need to do expensive content check
+						inspector.modified(fname);
 					} else {
-						inspector.clean(df.getPath());
+						HgDataFile df = repo.getFileNode(fname);
+						if (!df.exists()) {
+							String msg = String.format("File %s known as normal in dirstate (%d, %d), doesn't exist at %s", fname, r.modificationTime(), r.size(), repo.getStoragePath(df));
+							throw new HgInvalidFileException(msg, null).setFileName(fname);
+						}
+						Nodeid rev = getDirstateParentManifest().nodeid(fname);
+						// rev might be null here if fname comes to dirstate as a result of a merge operation
+						// where one of the parents (first parent) had no fname file, but second parent had.
+						// E.g. fork revision 3, revision 4 gets .hgtags, few modifications and merge(3,12)
+						// see Issue 14 for details
+						if (rev == null || !areTheSame(f, df, rev)) {
+							inspector.modified(df.getPath());
+						} else {
+							inspector.clean(df.getPath());
+						}
 					}
 				} catch (HgException ex) {
 					repo.getContext().getLog().warn(getClass(), ex, null);
@@ -374,7 +385,7 @@
 				} else if (!sizeEqual && r.size() >= 0) {
 					inspector.modified(fname);
 					handled = true;
-				} else if (!todoCheckFlagsEqual(f, flags)) {
+				} else if (!checkFlagsEqual(f, flags)) {
 					// seems like flags have changed, no reason to check content further
 					inspector.modified(fname);
 					handled = true;
@@ -516,9 +527,39 @@
 		}
 	}
 
-	private static boolean todoCheckFlagsEqual(FileInfo f, HgManifest.Flags originalManifestFlags) {
-		// FIXME implement
-		return true;
+	/**
+	 * @return <code>true</code> if flags are the same
+	 */
+	private boolean checkFlagsEqual(FileInfo f, HgManifest.Flags originalManifestFlags) {
+		boolean same = true;
+		if (repoWalker.supportsLinkFlag()) {
+			if (originalManifestFlags == HgManifest.Flags.Link) {
+				return f.isSymlink();
+			}
+			// original flag is not link, hence flags are the same if file is not link, too.
+			same = !f.isSymlink();
+		} // otherwise treat flags the same
+		if (repoWalker.supportsExecFlag()) {
+			if (originalManifestFlags == HgManifest.Flags.Exec) {
+				return f.isExecutable();
+			}
+			// original flag has no executable attribute, hence file shall not be executable, too
+			same = same || !f.isExecutable();
+		}
+		return same;
+	}
+	
+	private boolean checkFlagsEqual(FileInfo f, int dirstateFileMode) {
+		// source/include/linux/stat.h
+		final int S_IFLNK = 0120000, S_IXUSR = 00100;
+		// TODO post-1.0 HgManifest.Flags.parse(int)
+		if ((dirstateFileMode & S_IFLNK) == S_IFLNK) {
+			return checkFlagsEqual(f, HgManifest.Flags.Link);
+		}
+		if ((dirstateFileMode & S_IXUSR) == S_IXUSR) {
+			return checkFlagsEqual(f, HgManifest.Flags.Exec);
+		}
+		return checkFlagsEqual(f, null); // no flags
 	}
 
 	/**
@@ -580,16 +621,19 @@
 		private final Path[] paths;
 		private int index;
 		private RegularFileInfo nextFile;
+		private final boolean execCap, linkCap;
 
 		public FileListIterator(File startDir, Path... files) {
 			dir = startDir;
 			paths = files;
 			reset();
+			execCap = Internals.checkSupportsExecutables(startDir);
+			linkCap = Internals.checkSupportsSymlinks(startDir);
 		}
 
 		public void reset() {
 			index = -1;
-			nextFile = new RegularFileInfo();
+			nextFile = new RegularFileInfo(execCap, linkCap);
 		}
 
 		public boolean hasNext() {
@@ -620,6 +664,16 @@
 			}
 			return false;
 		}
+		
+		public boolean supportsExecFlag() {
+			// TODO Auto-generated method stub
+			return false;
+		}
+		
+		public boolean supportsLinkFlag() {
+			// TODO Auto-generated method stub
+			return false;
+		}
 	}
 	
 	private static class FileIteratorFilter implements FileIterator {
@@ -670,5 +724,13 @@
 		public boolean inScope(Path file) {
 			return filter.accept(file);
 		}
+		
+		public boolean supportsExecFlag() {
+			return walker.supportsExecFlag();
+		}
+		
+		public boolean supportsLinkFlag() {
+			return walker.supportsLinkFlag();
+		}
 	}
 }
--- a/src/org/tmatesoft/hg/util/FileInfo.java	Thu Mar 15 16:51:46 2012 +0100
+++ b/src/org/tmatesoft/hg/util/FileInfo.java	Wed Mar 21 20:40:28 2012 +0100
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2011 TMate Software Ltd
+ * Copyright (c) 2011-2012 TMate Software Ltd
  *  
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -47,4 +47,16 @@
 	 * @return file reader object, never <code>null</code>
 	 */
 	ReadableByteChannel newInputChannel();
+
+	/**
+	 * This method is invoked only if source FileIterator tells <code>true</code> for {@link FileIterator#supportsExecFlag()}
+	 * @return <code>true</code> if this object describes an executable file
+	 */
+	boolean isExecutable();
+
+	/**
+	 * This method is be invoked only if source FileIterator tells <code>true</code> for {@link FileIterator#supportsLinkFlag()}.
+	 * @return <code>true</code> if this file object represents a symbolic link
+	 */
+	boolean isSymlink();
 }
--- a/src/org/tmatesoft/hg/util/FileIterator.java	Thu Mar 15 16:51:46 2012 +0100
+++ b/src/org/tmatesoft/hg/util/FileIterator.java	Wed Mar 21 20:40:28 2012 +0100
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2011 TMate Software Ltd
+ * Copyright (c) 2011-2012 TMate Software Ltd
  *  
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -63,5 +63,20 @@
 	 * @return <code>true</code> if this {@link FileIterator} is responsible for (interested in) specified repository-local path 
 	 */
 	@Experimental(reason="Perhaps, shall not be part of FileIterator, but rather separate Path.Matcher. Approaches in regular StatusCollector (doesn't use FI, but supports scope) and WC collector to look similar, and for HgStatusCommand to use single approach to set the scope")
-	boolean inScope(Path file);
+	boolean inScope(Path file); // PathMatcher scope()
+
+	/**
+	 * Tells whether caller shall be aware of distinction between executable and non-executable files coming from this iterator.
+	 * Note, these days Mercurial (as of 2.1) doesn't recognize Windows .exe files as executable (nor it treats any Windows filesystem as exec-capable) 
+	 * @return <code>true</code> if file descriptors are capable to provide executable flag
+	 */
+	boolean supportsExecFlag();
+
+	/**
+	 * POSIX file systems allow symbolic links to files, and these links are handled in a special way with Mercurial, i.e. it tracks value of 
+	 * the link, not its actual target.
+	 * Note, these days Mercurial (as of 2.1) doesn't support Windows Vista/7 symlinks.
+	 * @return <code>true</code> if file descriptors are capable to tell symlink files from regular ones. 
+	 */
+	boolean supportsLinkFlag();
 }
--- a/src/org/tmatesoft/hg/util/FileWalker.java	Thu Mar 15 16:51:46 2012 +0100
+++ b/src/org/tmatesoft/hg/util/FileWalker.java	Wed Mar 21 20:40:28 2012 +0100
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2011 TMate Software Ltd
+ * Copyright (c) 2011-2012 TMate Software Ltd
  *  
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -20,7 +20,10 @@
 import java.util.LinkedList;
 import java.util.NoSuchElementException;
 
+import org.tmatesoft.hg.internal.Internals;
+
 /**
+ * Implementation of {@link FileIterator} using regular {@link java.io.File}
  * 
  * @author Artem Tikhomirov
  * @author TMate Software Ltd.
@@ -32,6 +35,7 @@
 	private final LinkedList<File> dirQueue;
 	private final LinkedList<File> fileQueue;
 	private final Path.Matcher scope;
+	private final boolean execCap, linkCap;
 	private RegularFileInfo nextFile;
 	private Path nextPath;
 
@@ -53,6 +57,8 @@
 		dirQueue = new LinkedList<File>();
 		fileQueue = new LinkedList<File>();
 		scope = scopeMatcher;
+		execCap = Internals.checkSupportsExecutables(startDir);
+		linkCap = Internals.checkSupportsSymlinks(startDir);
 		reset();
 	}
 
@@ -60,7 +66,7 @@
 		fileQueue.clear();
 		dirQueue.clear();
 		dirQueue.add(startDir);
-		nextFile = new RegularFileInfo();
+		nextFile = new RegularFileInfo(supportsExecFlag(), supportsLinkFlag());
 		nextPath = null;
 	}
 	
@@ -90,6 +96,14 @@
 		return scope == null ? true : scope.accept(file); 
 	}
 	
+	public boolean supportsExecFlag() {
+		return execCap;
+	}
+	
+	public boolean supportsLinkFlag() {
+		return linkCap;
+	}
+		
 	// returns non-null
 	private File[] listFiles(File f) {
 		// in case we need to solve os-related file issues (mac with some encodings?)
--- a/src/org/tmatesoft/hg/util/RegularFileInfo.java	Thu Mar 15 16:51:46 2012 +0100
+++ b/src/org/tmatesoft/hg/util/RegularFileInfo.java	Wed Mar 21 20:40:28 2012 +0100
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2011 TMate Software Ltd
+ * Copyright (c) 2011-2012 TMate Software Ltd
  *  
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -21,6 +21,7 @@
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
 import java.nio.channels.ReadableByteChannel;
 
 import org.tmatesoft.hg.internal.StreamLogFacility;
@@ -31,48 +32,111 @@
  * @author TMate Software Ltd.
  */
 public class RegularFileInfo implements FileInfo {
+	private final boolean supportsExec, supportsLink;
+	private final RegularFileStats fileFlagsHelper; // null if both supportsLink and supportExec are false
 	private File file;
 	
 	public RegularFileInfo() {
+		this(false, false);
+	}
+	public RegularFileInfo(boolean supportExecFlag, boolean supportSymlink) {
+		supportsLink = supportSymlink;
+		supportsExec = supportExecFlag;
+		if (supportSymlink || supportExecFlag) {
+			fileFlagsHelper = new RegularFileStats();
+		} else  {
+			fileFlagsHelper = null;
+		}
 	}
 	
 	public void init(File f) {
 		file = f;
+		if (fileFlagsHelper != null) {
+			fileFlagsHelper.init(file);
+		}
 	}
 	
 	public boolean exists() {
-		return file.canRead() && file.isFile();
+		// java.io.File for symlinks without proper target says it doesn't exist.
+		// since we found this symlink in directory listing, it's safe to say it exists just based on the fact it's link
+		return isSymlink() || (file.canRead() && file.isFile());
 	}
 
 	public int lastModified() {
+		// TODO post-1.0 for symlinks, this returns incorrect mtime of the target file, not that of link itself
+		// Besides, timestame if link points to non-existing file is 0.
+		// However, it result only in slowdown in WCStatusCollector, as it need to perform additional content check
 		return (int) (file.lastModified() / 1000);
 	}
 
 	public long length() {
+		if (isSymlink()) {
+			return getLinkTargetBytes().length;
+		}
 		return file.length();
 	}
 
 	public ReadableByteChannel newInputChannel() {
 		try {
-			return new FileInputStream(file).getChannel();
+			if (isSymlink()) {
+				return new ByteArrayReadableChannel(getLinkTargetBytes());
+			} else {
+				return new FileInputStream(file).getChannel();
+			}
 		} catch (FileNotFoundException ex) {
 			StreamLogFacility.newDefault().debug(getClass(), ex, null);
 			// shall not happen, provided this class is used correctly
-			return new ReadableByteChannel() {
-				
-				public boolean isOpen() {
-					return true;
-				}
-				
-				public void close() throws IOException {
-				}
-				
-				public int read(ByteBuffer dst) throws IOException {
-					// EOF right away
-					return -1;
-				}
-			};
+			return new ByteArrayReadableChannel(null);
 		}
 	}
 
+	public boolean isExecutable() {
+		return supportsExec && fileFlagsHelper.isExecutable();
+	}
+	
+	public boolean isSymlink() {
+		return supportsLink && fileFlagsHelper.isSymlink();
+	}
+	
+	private byte[] getLinkTargetBytes() {
+		assert isSymlink();
+		// no idea what encoding Mercurial uses for link targets, assume platform native is ok
+		return fileFlagsHelper.getSymlinkTarget().getBytes();
+	}
+
+
+	private static class ByteArrayReadableChannel implements ReadableByteChannel {
+		private final byte[] data;
+		private boolean closed = false; // initially open
+		private int firstAvailIndex = 0;
+		
+		ByteArrayReadableChannel(byte[] dataToStream) {
+			data = dataToStream;
+		}
+
+		public boolean isOpen() {
+			return !closed;
+		}
+
+		public void close() throws IOException {
+			closed = true;
+		}
+
+		public int read(ByteBuffer dst) throws IOException {
+			if (closed) {
+				throw new ClosedChannelException();
+			}
+			int remainingBytes = data.length - firstAvailIndex;
+			if (data == null || remainingBytes == 0) {
+				// EOF right away
+				return -1;
+			}
+			int x = Math.min(dst.remaining(), remainingBytes);
+			for (int i = firstAvailIndex, lim = firstAvailIndex + x; i < lim; i++) {
+				dst.put(data[i]);
+			}
+			firstAvailIndex += x;
+			return x;
+		}
+	}
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/util/RegularFileStats.java	Wed Mar 21 20:40:28 2012 +0100
@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2012 TMate Software Ltd
+ *  
+ * This program 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; version 2 of the License.
+ *
+ * This program 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.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.util;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.tmatesoft.hg.internal.Internals;
+import org.tmatesoft.hg.internal.ProcessExecHelper;
+
+/**
+ * Utility to collect executable files and symbolic links in a directory.
+ * 
+ * 
+ * Not public as present approach (expect file but collect once per directory) may need to be made explicit
+ * 
+ * TODO post-1.0 Add Linux-specific set of tests (similar to my test-flags repository, with symlink, executable and regular file,
+ * and few revisions where link and exec flags change. +testcase when link points to non-existing file (shall not report as missing, 
+ * iow either FileInfo.exist() shall respect symlinks or WCSC account for )
+ * 
+ * TODO post-1.0 Add extraction of link modification time, see RegularFileInfo#lastModified()
+ * 
+ * @author Artem Tikhomirov
+ * @author Tmate Software Ltd.
+ */
+/*package-local*/ class RegularFileStats {
+	private boolean isExec, isSymlink;
+	private String symlinkValue;
+	private final List<String> command;
+	private final ProcessExecHelper execHelper;
+	private final Matcher linkMatcher, execMatcher;
+	
+	
+	// directory name to (short link name -> link target)
+	private Map<String, Map<String, String>> dir2links = new TreeMap<String, Map<String, String>>();
+	// directory name to set of executable file short names
+	private Map<String, Set<String>> dir2execs = new TreeMap<String, Set<String>>();
+
+
+	RegularFileStats() {
+		if (Internals.runningOnWindows()) {
+			// XXX this implementation is not yet tested against any Windows repository, 
+			// only against sample dir listings. As long as Mercurial doesn't handle Windows
+			// links, we don't really need this
+			command = Arrays.asList("cmd", "/c", "dir");
+			// Windows patterns need to work against full directory listing (I didn't find a way 
+			// to list single file with its attributes like SYMLINK) 
+			Pattern pLink = Pattern.compile("^\\S+.*\\s+<SYMLINK>\\s+(\\S.*)\\s+\\[(.+)\\]$", Pattern.MULTILINE);
+			Pattern pExec = Pattern.compile("^\\S+.*\\s+\\d+\\s+(\\S.*\\.exe)$", Pattern.MULTILINE);
+			linkMatcher = pLink.matcher("");
+			execMatcher = pExec.matcher("");
+		} else {
+			command = Arrays.asList("/bin/ls", "-l", "-Q"); // -Q is essential to get quoted name - the only way to
+			// tell exact file name (which may start or end with spaces.
+			Pattern pLink = Pattern.compile("^lrwxrwxrwx\\s.*\\s\"(.*)\"\\s+->\\s+\"(.*)\"$", Pattern.MULTILINE);
+			// pLink: group(1) is full name if single file listing (ls -l /usr/bin/java) and short name if directory listing (ls -l /usr/bin)
+			//        group(2) is link target
+			Pattern pExec = Pattern.compile("^-..[sx]..[sx]..[sx]\\s.*\\s\"(.+)\"$", Pattern.MULTILINE);
+			// pExec: group(1) is name of executable file
+			linkMatcher = pLink.matcher("");
+			execMatcher = pExec.matcher("");
+		}
+		execHelper = new ProcessExecHelper();
+	}
+
+	public void init(File f) {
+		// can't check isFile because Java would say false for a symlink with non-existing target
+		if (f.isDirectory()) {
+			// perhaps, shall just collect stats for all files and set false to exec/link flags?
+			throw new IllegalArgumentException(); // FIXME EXCEPTIONS
+		}
+		final String dirName = f.getParentFile().getAbsolutePath();
+		final String fileName = f.getName();
+		Map<String, String> links = dir2links.get(dirName);
+		Set<String> execs = dir2execs.get(dirName);
+		if (links == null || execs == null) {
+			try {
+				ArrayList<String> cmd = new ArrayList<String>(command);
+				cmd.add(dirName);
+				CharSequence result = execHelper.exec(cmd);
+				
+				if (execMatcher.reset(result).find()) {
+					execs = new HashSet<String>();
+					do {
+						execs.add(execMatcher.group(1));
+					} while (execMatcher.find());
+				} else {
+					execs = Collections.emptySet(); // indicate we tried and found nothing
+				}
+				if (linkMatcher.reset(result).find()) {
+					links = new HashMap<String, String>();
+					do {
+						links.put(linkMatcher.group(1), linkMatcher.group(2));
+					} while (linkMatcher.find());
+				} else {
+					links = Collections.emptyMap();
+				}
+				dir2links.put(dirName, links);
+				dir2execs.put(dirName, execs);
+			} catch (InterruptedException ex) {
+				// try again? ensure not too long? stop right away?
+				// FIXME EXCEPTIONS
+				throw new RuntimeException();
+			} catch (IOException ex) {
+				// FIXME EXCEPTIONS perhaps, fail silently indicating false for both x and l?
+				throw new RuntimeException();
+			}
+		}
+		isExec = execs.contains(fileName);
+		isSymlink = links.containsKey(fileName);
+		if (isSymlink) {
+			symlinkValue = links.get(fileName);
+		} else {
+			symlinkValue = null;
+		}
+	}
+
+	public boolean isExecutable() {
+		return isExec;
+	}
+	
+	public boolean isSymlink() {
+		return isSymlink;
+	}
+
+	public String getSymlinkTarget() {
+		if (isSymlink) {
+			return symlinkValue;
+		}
+		throw new UnsupportedOperationException();
+	}
+}
--- a/test/org/tmatesoft/hg/test/ExecHelper.java	Thu Mar 15 16:51:46 2012 +0100
+++ b/test/org/tmatesoft/hg/test/ExecHelper.java	Wed Mar 21 20:40:28 2012 +0100
@@ -18,84 +18,55 @@
 
 import java.io.File;
 import java.io.IOException;
-import java.io.InputStreamReader;
-import java.nio.CharBuffer;
 import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.LinkedList;
+import java.util.List;
 import java.util.StringTokenizer;
 
+import org.tmatesoft.hg.internal.ProcessExecHelper;
+
 /**
  *
  * @author Artem Tikhomirov
  * @author TMate Software Ltd.
  */
-public class ExecHelper {
+public class ExecHelper extends ProcessExecHelper {
 
 	private final OutputParser parser;
-	private File dir;
-	private int exitValue;
 
 	public ExecHelper(OutputParser outParser, File workingDir) {
 		parser = outParser;
-		dir = workingDir;
+		super.cwd(workingDir);
 	}
-
-	public void run(String... cmd) throws IOException, InterruptedException {
-		ProcessBuilder pb = null;
+	
+	@Override
+	protected List<String> prepareCommand(List<String> cmd) {
+		String commandName = cmd.get(0);
 		if (System.getProperty("os.name").startsWith("Windows")) {
 			StringTokenizer st = new StringTokenizer(System.getenv("PATH"), ";");
 			while (st.hasMoreTokens()) {
 				File pe = new File(st.nextToken());
-				if (new File(pe, cmd[0] + ".exe").exists()) {
-					break;
+				if (new File(pe, commandName + ".exe").exists()) {
+					return cmd;
 				}
 				// PATHEXT controls precedence of .exe, .bat and .cmd files, usually .exe wins
-				if (new File(pe, cmd[0] + ".bat").exists() || new File(pe, cmd[0] + ".cmd").exists()) {
+				if (new File(pe, commandName + ".bat").exists() || new File(pe, commandName + ".cmd").exists()) {
 					ArrayList<String> command = new ArrayList<String>();
 					command.add("cmd.exe");
 					command.add("/C");
-					command.addAll(Arrays.asList(cmd));
-					pb = new ProcessBuilder(command);
-					break;
+					command.addAll(cmd);
+					return command;
 				}
 			}
 		}
-		if (pb == null) {
-			pb = new ProcessBuilder(cmd);
-		}
-		Process p = pb.directory(dir).redirectErrorStream(true).start();
-		InputStreamReader stdOut = new InputStreamReader(p.getInputStream());
-		LinkedList<CharBuffer> l = new LinkedList<CharBuffer>();
-		int r = -1;
-		CharBuffer b = null;
-		do {
-			if (b == null || b.remaining() < b.capacity() / 3) {
-				b = CharBuffer.allocate(512);
-				l.add(b);
-			}
-			r = stdOut.read(b);
-		} while (r != -1);
-		int total = 0;
-		for (CharBuffer cb : l) {
-			total += cb.position();
-			cb.flip();
-		}
-		CharBuffer res = CharBuffer.allocate(total);
-		for (CharBuffer cb : l) {
-			res.put(cb);
-		}
-		res.flip();
-		p.waitFor();
-		exitValue = p.exitValue();
+		return super.prepareCommand(cmd);
+	}
+	
+	public void run(String... cmd) throws IOException, InterruptedException {
+		CharSequence res = super.exec(cmd);
 		parser.parse(res);
 	}
-	
+
 	public int getExitValue() {
-		return exitValue;
-	}
-
-	public void cwd(File wd) {
-		dir = wd;
+		return super.exitValue();
 	}
 }