CLI status should support --porcelain

Add support for the machine-readable output format along with the
existing default long format.

Bug: 419968
Change-Id: I37fe5121b4c9dbae1106b1d18e9fdc134070a9dd
Signed-off-by: Kaloyan Raev <kaloyan.r@zend.com>
diff --git a/org.eclipse.jgit.pgm.test/tst/org/eclipse/jgit/pgm/StatusTest.java b/org.eclipse.jgit.pgm.test/tst/org/eclipse/jgit/pgm/StatusTest.java
index 73ae598..acc2be6 100644
--- a/org.eclipse.jgit.pgm.test/tst/org/eclipse/jgit/pgm/StatusTest.java
+++ b/org.eclipse.jgit.pgm.test/tst/org/eclipse/jgit/pgm/StatusTest.java
@@ -213,4 +213,122 @@ public void testStatus() throws Exception {
 						"" //
 				}, execute("git status")); //
 	}
+
+	@Test
+	public void testStatusPorcelain() throws Exception {
+		Git git = new Git(db);
+		// Write all files
+		writeTrashFile("tracked", "tracked");
+		writeTrashFile("stagedNew", "stagedNew");
+		writeTrashFile("stagedModified", "stagedModified");
+		writeTrashFile("stagedDeleted", "stagedDeleted");
+		writeTrashFile("trackedModified", "trackedModified");
+		writeTrashFile("trackedDeleted", "trackedDeleted");
+		writeTrashFile("untracked", "untracked");
+		// Test untracked
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"?? stagedDeleted", //
+						"?? stagedModified", //
+						"?? stagedNew", //
+						"?? tracked", //
+						"?? trackedDeleted", //
+						"?? trackedModified", //
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+		// Add to index
+		git.add().addFilepattern("tracked").call();
+		git.add().addFilepattern("stagedModified").call();
+		git.add().addFilepattern("stagedDeleted").call();
+		git.add().addFilepattern("trackedModified").call();
+		git.add().addFilepattern("trackedDeleted").call();
+		// Test staged count
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"A  stagedDeleted", //
+						"A  stagedModified", //
+						"A  tracked", //
+						"A  trackedDeleted", //
+						"A  trackedModified", //
+						"?? stagedNew", //
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+		// Commit
+		git.commit().setMessage("initial commit").call();
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"?? stagedNew", //
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+		// Make some changes and stage them
+		writeTrashFile("stagedModified", "stagedModified modified");
+		deleteTrashFile("stagedDeleted");
+		writeTrashFile("trackedModified", "trackedModified modified");
+		deleteTrashFile("trackedDeleted");
+		git.add().addFilepattern("stagedModified").call();
+		git.rm().addFilepattern("stagedDeleted").call();
+		git.add().addFilepattern("stagedNew").call();
+		// Test staged/not-staged status
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"D  stagedDeleted", //
+						"M  stagedModified", //
+						"A  stagedNew", //
+						" D trackedDeleted", //
+						" M trackedModified", //
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+		// Create unmerged file
+		writeTrashFile("unmerged", "unmerged");
+		git.add().addFilepattern("unmerged").call();
+		// Commit pending changes
+		git.add().addFilepattern("trackedModified").call();
+		git.rm().addFilepattern("trackedDeleted").call();
+		git.commit().setMessage("commit before branching").call();
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+		// Checkout new branch
+		git.checkout().setCreateBranch(true).setName("test").call();
+		// Test branch status
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+		// Commit change and checkout master again
+		writeTrashFile("unmerged", "changed in test branch");
+		git.add().addFilepattern("unmerged").call();
+		RevCommit testBranch = git.commit()
+				.setMessage("changed unmerged in test branch").call();
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+		git.checkout().setName("master").call();
+		// Change the same file and commit
+		writeTrashFile("unmerged", "changed in master branch");
+		git.add().addFilepattern("unmerged").call();
+		git.commit().setMessage("changed unmerged in master branch").call();
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+		// Merge test branch into master
+		git.merge().include(testBranch.getId()).call();
+		// Test unmerged status
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"UU unmerged", //
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+		// Test detached head
+		String commitId = db.getRef(Constants.MASTER).getObjectId().name();
+		git.checkout().setName(commitId).call();
+		assertArrayOfLinesEquals(new String[] { // git status output
+						"UU unmerged", //
+						"?? untracked", //
+						"" //
+				}, execute("git status --porcelain")); //
+	}
 }
diff --git a/org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties b/org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties
index fc83b95..d23f378 100644
--- a/org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties
+++ b/org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties
@@ -282,6 +282,7 @@
 usage_listBothRemoteTrackingAndLocalBranches=list both remote-tracking and local branches
 usage_listCreateOrDeleteBranches=List, create, or delete branches
 usage_logAllPretty=format:%H %ct %P' output=log --all '--pretty=format:%H %ct %P' output
+usage_machineReadableOutput=machine-readable output
 usage_manageReflogInformation=Manage reflog information
 usage_mergeFf=When the merge resolves as a fast-forward, only update the branch pointer, without creating a merge commit.
 usage_mergeNoFf=Create a merge commit even when the merge resolves as a fast-forward.
diff --git a/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Status.java b/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Status.java
index 0214ed0..2ae950b 100644
--- a/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Status.java
+++ b/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Status.java
@@ -50,6 +50,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.TreeSet;
 
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.StatusCommand;
@@ -71,26 +72,134 @@ class Status extends TextBuiltin {
 
 	protected final String statusFileListFormatUnmerged = CLIText.get().statusFileListFormatUnmerged;
 
+	@Option(name = "--porcelain", usage = "usage_machineReadableOutput")
+	protected boolean porcelain;
+
 	@Option(name = "--", metaVar = "metaVar_path", multiValued = true)
 	protected List<String> filterPaths;
 
 	@Override
 	protected void run() throws Exception {
-		// Print current branch name
-		final Ref head = db.getRef(Constants.HEAD);
-		boolean firstHeader = true;
-		if (head != null && head.isSymbolic()) {
-			String branch = Repository.shortenRefName(head.getLeaf().getName());
-			outw.println(CLIText.formatLine(
-					MessageFormat.format(CLIText.get().onBranch, branch)));
-		} else
-			outw.println(CLIText.formatLine(CLIText.get().notOnAnyBranch));
-		// List changes
 		StatusCommand statusCommand = new Git(db).status();
 		if (filterPaths != null && filterPaths.size() > 0)
 			for (String path : filterPaths)
 				statusCommand.addPath(path);
 		org.eclipse.jgit.api.Status status = statusCommand.call();
+		printStatus(status);
+	}
+
+	private void printStatus(org.eclipse.jgit.api.Status status)
+			throws IOException {
+		if (porcelain)
+			printPorcelainStatus(status);
+		else
+			printLongStatus(status);
+	}
+
+	private void printPorcelainStatus(org.eclipse.jgit.api.Status status)
+			throws IOException {
+
+		Collection<String> added = status.getAdded();
+		Collection<String> changed = status.getChanged();
+		Collection<String> removed = status.getRemoved();
+		Collection<String> modified = status.getModified();
+		Collection<String> missing = status.getMissing();
+		Map<String, StageState> conflicting = status.getConflictingStageState();
+
+		// build a sorted list of all paths except untracked and ignored
+		TreeSet<String> sorted = new TreeSet<String>();
+		sorted.addAll(added);
+		sorted.addAll(changed);
+		sorted.addAll(removed);
+		sorted.addAll(modified);
+		sorted.addAll(missing);
+		sorted.addAll(conflicting.keySet());
+
+		// list each path
+		for (String path : sorted) {
+			char x = ' ';
+			char y = ' ';
+
+			if (added.contains(path))
+				x = 'A';
+			else if (changed.contains(path))
+				x = 'M';
+			else if (removed.contains(path))
+				x = 'D';
+
+			if (modified.contains(path))
+				y = 'M';
+			else if (missing.contains(path))
+				y = 'D';
+
+			if (conflicting.containsKey(path)) {
+				StageState stageState = conflicting.get(path);
+
+				switch (stageState) {
+				case BOTH_DELETED:
+					x = 'D';
+					y = 'D';
+					break;
+				case ADDED_BY_US:
+					x = 'A';
+					y = 'U';
+					break;
+				case DELETED_BY_THEM:
+					x = 'U';
+					y = 'D';
+					break;
+				case ADDED_BY_THEM:
+					x = 'U';
+					y = 'A';
+					break;
+				case DELETED_BY_US:
+					x = 'D';
+					y = 'U';
+					break;
+				case BOTH_ADDED:
+					x = 'A';
+					y = 'A';
+					break;
+				case BOTH_MODIFIED:
+					x = 'U';
+					y = 'U';
+					break;
+				default:
+					throw new IllegalArgumentException("Unknown StageState: " //$NON-NLS-1$
+							+ stageState);
+				}
+			}
+
+			printPorcelainLine(x, y, path);
+		}
+
+		// untracked are always at the end of the list
+		TreeSet<String> untracked = new TreeSet<String>(status.getUntracked());
+		for (String path : untracked)
+			printPorcelainLine('?', '?', path);
+	}
+
+	private void printPorcelainLine(char x, char y, String path)
+			throws IOException {
+		StringBuilder lineBuilder = new StringBuilder();
+		lineBuilder.append(x).append(y).append(' ').append(path);
+		outw.println(lineBuilder.toString());
+	}
+
+	private void printLongStatus(org.eclipse.jgit.api.Status status)
+			throws IOException {
+		// Print current branch name
+		final Ref head = db.getRef(Constants.HEAD);
+		if (head != null && head.isSymbolic()) {
+			String branch = Repository.shortenRefName(head.getLeaf().getName());
+			outw.println(CLIText.formatLine(MessageFormat.format(
+					CLIText.get().onBranch, branch)));
+		} else
+			outw.println(CLIText.formatLine(CLIText.get().notOnAnyBranch));
+
+		// List changes
+		boolean firstHeader = true;
+
 		Collection<String> added = status.getAdded();
 		Collection<String> changed = status.getChanged();
 		Collection<String> removed = status.getRemoved();