v.io/jiri: add project runp and project info commands.

jiri runp will run commands in parallel in each of a specified
set of jiri projects. jiri project info provides structured
access to project information.

MultiPart: 1/2

Change-Id: I173b8f1d9f41b36b6f67fde3df0772501d8d3273
diff --git a/cmd/jiri/cl.go b/cmd/jiri/cl.go
index 1950a78..2ddc965 100644
--- a/cmd/jiri/cl.go
+++ b/cmd/jiri/cl.go
@@ -335,7 +335,7 @@
 
 // currentProject returns the Project containing the current working directory.
 // The current working directory must be inside JIRI_ROOT.
-func currentProject(jirix *jiri.X) (project.Project, error) {
+func currentProject(jirix *jiri.X, cmd string) (project.Project, error) {
 	dir, err := os.Getwd()
 	if err != nil {
 		return project.Project{}, fmt.Errorf("os.Getwd() failed: %v", err)
@@ -343,7 +343,7 @@
 
 	// Error if current working dir is not inside jirix.Root.
 	if !strings.HasPrefix(dir, jirix.Root) {
-		return project.Project{}, fmt.Errorf("'jiri cl mail' must be run from within a project in JIRI_ROOT")
+		return project.Project{}, fmt.Errorf("'%s' must be run from within a project in JIRI_ROOT", cmd)
 	}
 
 	// Walk up the path until we find a project at that path, or hit the jirix.Root.
@@ -385,7 +385,7 @@
 			strings.Join(gerrit.PresubmitTestTypes(), ","))
 	}
 
-	p, err := currentProject(jirix)
+	p, err := currentProject(jirix, "jiri cl mail")
 	if err != nil {
 		return err
 	}
diff --git a/cmd/jiri/doc.go b/cmd/jiri/doc.go
index 61a3e10..5215073 100644
--- a/cmd/jiri/doc.go
+++ b/cmd/jiri/doc.go
@@ -21,6 +21,7 @@
    snapshot     Manage project snapshots
    update       Update all jiri tools and projects
    which        Show path to the jiri tool
+   runp         Run a command in parallel across jiri projects
    help         Display help for commands or topics
 
 The jiri additional help topics are:
@@ -469,6 +470,8 @@
 
 The jiri project commands are:
    clean        Restore jiri projects to their pristine state
+   info         Provided structured input for existing jiri projects and
+                branches
    list         List existing jiri projects and branches
    shell-prompt Print a succinct status of projects suitable for shell prompts
    poll         Poll existing jiri projects
@@ -498,6 +501,35 @@
  -v=false
    Print verbose output.
 
+Jiri project info - Provided structured input for existing jiri projects and branches
+
+Inspect the local filesystem and provide structured info on the existing
+projects and branches. Projects are specified using regular expressions that are
+matched against project keys. If no command line arguments are provided the
+project that the contains the current directory is used. The information to be
+displayed is specified using a go template, supplied via the -f flag, that is
+executed against the v.io/jiri/project.ProjectState structure. This structure
+currently has the following fields:
+project.ProjectState{Branches:[]project.BranchState(nil), CurrentBranch:"",
+HasUncommitted:false, HasUntracked:false, Project:project.Project{Name:"",
+Path:"", Protocol:"", Remote:"", RemoteBranch:"", Revision:"", GerritHost:"",
+GitHooks:"", RunHook:"", XMLName:struct {}{}}}
+
+Usage:
+   jiri project info [flags] <project-keys>...
+
+<project-keys>... a list of project keys, as regexps, to apply the specified
+format to
+
+The jiri project info flags are:
+ -f={{.Project.Name}}
+   The go template for the fields to display.
+
+ -color=true
+   Use color to format output.
+ -v=false
+   Print verbose output.
+
 Jiri project list - List existing jiri projects and branches
 
 Inspect the local filesystem and list the existing projects and branches.
@@ -737,6 +769,73 @@
  -v=false
    Print verbose output.
 
+Jiri runp - Run a command in parallel across jiri projects
+
+Run a command in parallel across one or more jiri projects using the specified
+profile target's environment. Commands are run using the shell specified by the
+users $SHELL environment variable, or "sh" if that's not set. Thus commands are
+run as $SHELL -c "args..."
+
+Usage:
+   jiri runp [flags] <command line>
+
+A command line to be run in each project specified by the supplied command line
+flags. Any environment variables intended to be evaluated when the command line
+is run must be quoted to avoid expansion before being passed to runp by the
+shell.
+
+The jiri runp flags are:
+ -collate-stdout=true
+   Collate all stdout output from each parallel invocation and display it as if
+   had been generated sequentially. This flag cannot be used with
+   -show-name-prefix, -show-key-prefix or -interactive.
+ -env=
+   specify an environment variable in the form: <var>=[<val>],...
+ -exit-on-error=false
+   If set, all commands will killed as soon as one reports an error, otherwise,
+   each will run to completion.
+ -has-gerrit-message=false
+   If specified, match branches that have, or have no, gerrit message
+ -has-uncommitted=false
+   If specified, match projects that have, or have no, uncommitted changes
+ -has-untracked=false
+   if specified, match projects that have, or have no, untracked files
+ -interactive=true
+   If set, the command to be run is interactive and should not have its
+   stdout/stderr manipulated. This flag cannot be used with -show-name-prefix,
+   -show-key-prefix or -collate-stdout.
+ -merge-policies=+CCFLAGS,+CGO_CFLAGS,+CGO_CXXFLAGS,+CGO_LDFLAGS,+CXXFLAGS,GOARCH,GOOS,GOPATH:,^GOROOT*,+LDFLAGS,:PATH,VDLPATH:
+   specify policies for merging environment variables
+ -profiles=v23:base,jiri
+   a comma separated list of profiles to use
+ -profiles-db=$JIRI_ROOT/.jiri_root/profile_db
+   the path, relative to JIRI_ROOT, that contains the profiles database.
+ -projects=
+   A Regular expression specifying project keys to run commands in. By default,
+   runp will use projects that have the same branch checked as the current
+   project.
+ -show-key-prefix=false
+   If set, each line of output from each project will begin with the key of the
+   project followed by a colon. This is intended for use with long running
+   commands where the output needs to be streamed. Stdout and stderr are spliced
+   apart. This flag cannot be used with -interactive, -show-name-prefix or
+   -collate-stdout
+ -show-name-prefix=false
+   If set, each line of output from each project will begin with the name of the
+   project followed by a colon. This is intended for use with long running
+   commands where the output needs to be streamed. Stdout and stderr are spliced
+   apart. This flag cannot be used with -interactive, -show-key-prefix or
+   -collate-stdout.
+ -skip-profiles=false
+   if set, no profiles will be used
+ -target=<runtime.GOARCH>-<runtime.GOOS>
+   specifies a profile target in the following form: <arch>-<os>[@<version>]
+ -v=false
+   Print verbose logging information
+
+ -color=true
+   Use color to format output.
+
 Jiri help - Display help for commands or topics
 
 Help with no args displays the usage of the parent command.
diff --git a/cmd/jiri/project.go b/cmd/jiri/project.go
index dc6e7f6..86ced20 100644
--- a/cmd/jiri/project.go
+++ b/cmd/jiri/project.go
@@ -5,11 +5,14 @@
 package main
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
 	"path/filepath"
+	"regexp"
 	"sort"
 	"strings"
+	"text/template"
 
 	"v.io/jiri"
 	"v.io/jiri/project"
@@ -25,6 +28,7 @@
 	noPristineFlag      bool
 	checkDirtyFlag      bool
 	showNameFlag        bool
+	formatFlag          string
 )
 
 func init() {
@@ -33,7 +37,7 @@
 	cmdProjectList.Flags.BoolVar(&noPristineFlag, "nopristine", false, "If true, omit pristine projects, i.e. projects with a clean master branch and no other branches.")
 	cmdProjectShellPrompt.Flags.BoolVar(&checkDirtyFlag, "check-dirty", true, "If false, don't check for uncommitted changes or untracked files. Setting this option to false is dangerous: dirty master branches will not appear in the output.")
 	cmdProjectShellPrompt.Flags.BoolVar(&showNameFlag, "show-name", false, "Show the name of the current repo.")
-
+	cmdProjectInfo.Flags.StringVar(&formatFlag, "f", "{{.Project.Name}}", "The go template for the fields to display.")
 	tool.InitializeProjectFlags(&cmdProjectPoll.Flags)
 
 }
@@ -43,7 +47,7 @@
 	Name:     "project",
 	Short:    "Manage the jiri projects",
 	Long:     "Manage the jiri projects.",
-	Children: []*cmdline.Command{cmdProjectClean, cmdProjectList, cmdProjectShellPrompt, cmdProjectPoll},
+	Children: []*cmdline.Command{cmdProjectClean, cmdProjectInfo, cmdProjectList, cmdProjectShellPrompt, cmdProjectPoll},
 }
 
 // cmdProjectClean represents the "jiri project clean" command.
@@ -126,6 +130,93 @@
 	return nil
 }
 
+// cmdProjectInfo represents the "jiri project info" command.
+var cmdProjectInfo = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runProjectInfo),
+	Name:   "info",
+	Short:  "Provided structured input for existing jiri projects and branches",
+	Long: `
+Inspect the local filesystem and provide structured info on the existing projects
+and branches. Projects are specified using regular expressions that are matched
+against project keys. If no command line arguments are provided the project
+that the contains the current directory is used. The information to be
+displayed is specified using a go template, supplied via the -f flag, that is
+executed against the v.io/jiri/project.ProjectState structure. This structure
+currently has the following fields: ` + fmt.Sprintf("%#v", project.ProjectState{}),
+	ArgsName: "<project-keys>...",
+	ArgsLong: "<project-keys>... a list of project keys, as regexps, to apply the specified format to",
+}
+
+// runProjectInfo provides structured info on local projects.
+func runProjectInfo(jirix *jiri.X, args []string) error {
+	tmpl, err := template.New("info").Parse(formatFlag)
+	if err != nil {
+		return fmt.Errorf("failed to parse template %q: %v", formatFlag, err)
+	}
+	regexps := []*regexp.Regexp{}
+
+	if len(args) > 0 {
+		regexps = make([]*regexp.Regexp, len(args), len(args))
+		for i, a := range args {
+			re, err := regexp.Compile(a)
+			if err != nil {
+				return fmt.Errorf("failed to compile regexp %v: %v", a, err)
+			}
+			regexps[i] = re
+		}
+	}
+
+	dirty := false
+	for _, slow := range []string{"HasUncommitted", "HasUntracked"} {
+		if strings.Contains(formatFlag, slow) {
+			dirty = true
+			break
+		}
+	}
+
+	var states map[project.ProjectKey]*project.ProjectState
+	var keys project.ProjectKeys
+	if len(args) == 0 {
+		currentProjectKey, err := project.CurrentProjectKey(jirix)
+		if err != nil {
+			return err
+		}
+		state, err := project.GetProjectState(jirix, currentProjectKey, true)
+		if err != nil {
+			return err
+		}
+		states = map[project.ProjectKey]*project.ProjectState{
+			currentProjectKey: state,
+		}
+		keys = append(keys, currentProjectKey)
+	} else {
+		var err error
+		states, err = project.GetProjectStates(jirix, dirty)
+		if err != nil {
+			return err
+		}
+		for key := range states {
+			for _, re := range regexps {
+				if re.MatchString(string(key)) {
+					keys = append(keys, key)
+					break
+				}
+			}
+		}
+	}
+	sort.Sort(keys)
+
+	for _, key := range keys {
+		state := states[key]
+		out := &bytes.Buffer{}
+		if err = tmpl.Execute(out, state); err != nil {
+			return jirix.UsageErrorf("invalid format")
+		}
+		fmt.Fprintln(jirix.Stdout(), out.String())
+	}
+	return nil
+}
+
 // cmdProjectShellPrompt represents the "jiri project shell-prompt" command.
 var cmdProjectShellPrompt = &cmdline.Command{
 	Runner: jiri.RunnerFunc(runProjectShellPrompt),
diff --git a/cmd/jiri/runp.go b/cmd/jiri/runp.go
new file mode 100644
index 0000000..381a18f
--- /dev/null
+++ b/cmd/jiri/runp.go
@@ -0,0 +1,418 @@
+// Copyright 2015 The Vanadium Authors, All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"bufio"
+	"flag"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"os/signal"
+	"regexp"
+	"sort"
+	"strings"
+	"sync"
+
+	"v.io/jiri"
+	"v.io/jiri/gitutil"
+	"v.io/jiri/profiles/profilescmdline"
+	"v.io/jiri/profiles/profilesreader"
+	"v.io/jiri/project"
+	"v.io/jiri/tool"
+	"v.io/x/lib/cmdline"
+	"v.io/x/lib/envvar"
+	"v.io/x/lib/simplemr"
+)
+
+var (
+	cmdRunP   *cmdline.Command
+	runpFlags runpFlagValues
+)
+
+func newRunP() *cmdline.Command {
+	return &cmdline.Command{
+		Runner: jiri.RunnerFunc(runRunp),
+		Name:   "runp",
+		Short:  "Run a command in parallel across jiri projects",
+		Long: `
+Run a command in parallel across one or more jiri projects using the specified
+profile target's environment. Commands are run using the shell specified by the
+users $SHELL environment variable, or "sh" if that's not set. Thus commands
+are run as $SHELL -c "args..."
+ `,
+		ArgsName: "<command line>",
+		ArgsLong: `
+	A command line to be run in each project specified by the supplied command
+line flags. Any environment variables intended to be evaluated when the
+command line is run must be quoted to avoid expansion before being passed to
+runp by the shell.
+`,
+	}
+}
+
+type runpFlagValues struct {
+	profilescmdline.ReaderFlagValues
+	projectKeys      string
+	verbose          bool
+	interactive      bool
+	hasUncommitted   bool
+	hasUntracked     bool
+	hasGerritMessage bool
+	showNamePrefix   bool
+	showKeyPrefix    bool
+	exitOnError      bool
+	collateOutput    bool
+	editMessage      bool
+}
+
+func registerCommonFlags(flags *flag.FlagSet, values *runpFlagValues) {
+	profilescmdline.RegisterReaderFlags(flags, &values.ReaderFlagValues, jiri.ProfilesDBDir)
+	flags.BoolVar(&values.verbose, "v", false, "Print verbose logging information")
+	flags.StringVar(&values.projectKeys, "projects", "", "A Regular expression specifying project keys to run commands in. By default, runp will use projects that have the same branch checked as the current project.")
+	flags.BoolVar(&values.hasUncommitted, "has-uncommitted", false, "If specified, match projects that have, or have no, uncommitted changes")
+	flags.BoolVar(&values.hasUntracked, "has-untracked", false, "if specified, match projects that have, or have no, untracked files")
+	flags.BoolVar(&values.hasGerritMessage, "has-gerrit-message", false, "If specified, match branches that have, or have no, gerrit message")
+	flags.BoolVar(&values.interactive, "interactive", true, "If set, the command to be run is interactive and should not have its stdout/stderr manipulated. This flag cannot be used with -show-name-prefix, -show-key-prefix or -collate-stdout.")
+	flags.BoolVar(&values.showNamePrefix, "show-name-prefix", false, "If set, each line of output from each project will begin with the name of the project followed by a colon. This is intended for use with long running commands where the output needs to be streamed. Stdout and stderr are spliced apart. This flag cannot be used with -interactive, -show-key-prefix or -collate-stdout.")
+	flags.BoolVar(&values.showKeyPrefix, "show-key-prefix", false, "If set, each line of output from each project will begin with the key of the project followed by a colon. This is intended for use with long running commands where the output needs to be streamed. Stdout and stderr are spliced apart. This flag cannot be used with -interactive, -show-name-prefix or -collate-stdout")
+	flags.BoolVar(&values.collateOutput, "collate-stdout", true, "Collate all stdout output from each parallel invocation and display it as if had been generated sequentially. This flag cannot be used with -show-name-prefix, -show-key-prefix or -interactive.")
+	flags.BoolVar(&values.exitOnError, "exit-on-error", false, "If set, all commands will killed as soon as one reports an error, otherwise, each will run to completion.")
+}
+
+func init() {
+	// Avoid an intialization loop between cmdline.Command.Runner which
+	// refers to cmdRunP and runRunp referring back to cmdRunP.ParsedFlags.
+	cmdRunP = newRunP()
+	cmdRoot.Children = append(cmdRoot.Children, cmdRunP)
+	registerCommonFlags(&cmdRunP.Flags, &runpFlags)
+}
+
+type mapInput struct {
+	*project.ProjectState
+	key          project.ProjectKey
+	jirix        *jiri.X
+	index, total int
+	result       error
+}
+
+func newmapInput(jirix *jiri.X, state *project.ProjectState, key project.ProjectKey, index, total int) *mapInput {
+	return &mapInput{
+		ProjectState: state,
+		key:          key,
+		jirix:        jirix.Clone(tool.ContextOpts{}),
+		index:        index,
+		total:        total,
+	}
+}
+
+func stateNames(states map[project.ProjectKey]*mapInput) []string {
+	n := []string{}
+	for _, state := range states {
+		n = append(n, state.Project.Name)
+	}
+	sort.Strings(n)
+	return n
+}
+
+func stateKeys(states map[project.ProjectKey]*mapInput) []string {
+	n := []string{}
+	for key := range states {
+		n = append(n, string(key))
+	}
+	sort.Strings(n)
+	return n
+}
+
+type runner struct {
+	args                 []string
+	reader               *profilesreader.Reader
+	serializedWriterLock sync.Mutex
+	collatedOutputLock   sync.Mutex
+}
+
+func (r *runner) serializedWriter(w io.Writer) io.Writer {
+	return &sharedLockWriter{&r.serializedWriterLock, w}
+}
+
+type sharedLockWriter struct {
+	mu *sync.Mutex
+	f  io.Writer
+}
+
+func (lw *sharedLockWriter) Write(d []byte) (int, error) {
+	lw.mu.Lock()
+	defer lw.mu.Unlock()
+	return lw.f.Write(d)
+}
+
+func copyWithPrefix(prefix string, w io.Writer, r io.Reader) {
+	reader := bufio.NewReader(r)
+	for {
+		line, err := reader.ReadString('\n')
+		if err != nil {
+			if line != "" {
+				fmt.Fprintf(w, "%v: %v\n", prefix, line)
+			}
+			break
+		}
+		fmt.Fprintf(w, "%v: %v", prefix, line)
+	}
+}
+
+type mapOutput struct {
+	mi             *mapInput
+	outputFilename string
+	key            string
+	err            error
+}
+
+func (r *runner) Map(mr *simplemr.MR, key string, val interface{}) error {
+	mi := val.(*mapInput)
+	output := &mapOutput{
+		key: key,
+		mi:  mi}
+	jirix := mi.jirix
+	path := os.Getenv("SHELL")
+	if path == "" {
+		path = "sh"
+	}
+	var wg sync.WaitGroup
+	cmd := exec.Command(path, "-c", strings.Join(r.args, " "))
+	cmd.Env = envvar.MapToSlice(jirix.Env())
+	cmd.Dir = mi.ProjectState.Project.Path
+	cmd.Stdin = mi.jirix.Stdin()
+	var stdoutCloser, stderrCloser io.Closer
+	if runpFlags.interactive {
+		cmd.Stdout = jirix.Stdout()
+		cmd.Stderr = jirix.Stderr()
+	} else {
+		var stdout io.Writer
+		stderr := r.serializedWriter(jirix.Stderr())
+		var cleanup func()
+		if runpFlags.collateOutput {
+			// Write standard output to a file, stderr
+			// is not collated.
+			f, err := ioutil.TempFile("", mi.ProjectState.Project.Name+"-")
+			if err != nil {
+				return err
+			}
+			stdout = f
+			output.outputFilename = f.Name()
+			cleanup = func() {
+				os.Remove(output.outputFilename)
+			}
+			// The child process will have exited by the
+			// time this method returns so it's safe to close the file
+			// here.
+			defer f.Close()
+		} else {
+			stdout = r.serializedWriter(jirix.Stdout())
+			cleanup = func() {}
+		}
+		if !runpFlags.showNamePrefix && !runpFlags.showKeyPrefix {
+			// write directly to stdout, stderr if there's no prefix
+			cmd.Stdout = stdout
+			cmd.Stderr = stderr
+		} else {
+			stdoutReader, stdoutWriter, err := os.Pipe()
+			if err != nil {
+				cleanup()
+				return err
+			}
+			stderrReader, stderrWriter, err := os.Pipe()
+			if err != nil {
+				cleanup()
+				stdoutReader.Close()
+				stdoutWriter.Close()
+				return err
+			}
+			cmd.Stdout = stdoutWriter
+			cmd.Stderr = stderrWriter
+			// Record the write end of the pipe so that it can be closed
+			// after the child has exited, this ensures that all goroutines
+			// will finish.
+			stdoutCloser = stdoutWriter
+			stderrCloser = stderrWriter
+			prefix := key
+			if runpFlags.showNamePrefix {
+				prefix = mi.ProjectState.Project.Name
+			}
+			wg.Add(2)
+			go func() { copyWithPrefix(prefix, stdout, stdoutReader); wg.Done() }()
+			go func() { copyWithPrefix(prefix, stderr, stderrReader); wg.Done() }()
+
+		}
+	}
+	if err := cmd.Start(); err != nil {
+		mi.result = err
+	}
+	done := make(chan error)
+	go func() {
+		done <- cmd.Wait()
+	}()
+	select {
+	case output.err = <-done:
+		if output.err != nil && runpFlags.exitOnError {
+			mr.Cancel()
+		}
+	case <-mr.CancelCh():
+		output.err = cmd.Process.Kill()
+	}
+	for _, closer := range []io.Closer{stdoutCloser, stderrCloser} {
+		if closer != nil {
+			closer.Close()
+		}
+	}
+	wg.Wait()
+	mr.MapOut(key, output)
+	return nil
+}
+
+func (r *runner) Reduce(mr *simplemr.MR, key string, values []interface{}) error {
+	for _, v := range values {
+		mo := v.(*mapOutput)
+		jirix := mo.mi.jirix
+		if mo.err != nil {
+			fmt.Fprintf(jirix.Stdout(), "FAILED: %v: %s %v\n", mo.key, strings.Join(r.args, " "), mo.err)
+			return mo.err
+		} else {
+			if runpFlags.collateOutput {
+				r.collatedOutputLock.Lock()
+				defer r.collatedOutputLock.Unlock()
+				defer os.Remove(mo.outputFilename)
+				if fi, err := os.Open(mo.outputFilename); err == nil {
+					io.Copy(jirix.Stdout(), fi)
+					fi.Close()
+				} else {
+					return err
+				}
+			}
+		}
+	}
+	return nil
+}
+
+func runp(jirix *jiri.X, cmd *cmdline.Command, args []string) error {
+	hasUntrackedSet := profilescmdline.IsFlagSet(cmd.ParsedFlags, "has-untracked")
+	hasUncommitedSet := profilescmdline.IsFlagSet(cmd.ParsedFlags, "has-uncommitted")
+	hasGerritSet := profilescmdline.IsFlagSet(cmd.ParsedFlags, "has-gerrit-message")
+
+	if runpFlags.interactive {
+		runpFlags.collateOutput = false
+	}
+
+	var keysRE *regexp.Regexp
+	var err error
+
+	if profilescmdline.IsFlagSet(cmd.ParsedFlags, "projects") {
+		keysRE, err = regexp.Compile(runpFlags.projectKeys)
+		if err != nil {
+			return fmt.Errorf("failed to compile projects regexp: %q: %v", runpFlags.projectKeys, err)
+		}
+	}
+
+	for _, f := range []string{"show-key-prefix", "show-name-prefix"} {
+		if profilescmdline.IsFlagSet(cmd.ParsedFlags, f) {
+			runpFlags.interactive = false
+			runpFlags.collateOutput = true
+			break
+		}
+	}
+
+	git := gitutil.New(jirix.NewSeq())
+	homeBranch, err := git.CurrentBranchName()
+	if err != nil {
+		return fmt.Errorf("failed to determine name of current git branch: %s", err)
+	}
+
+	dirty := false
+	if hasUntrackedSet || hasUncommitedSet {
+		dirty = true
+	}
+	states, err := project.GetProjectStates(jirix, dirty)
+	if err != nil {
+		return err
+	}
+	mapInputs := map[project.ProjectKey]*mapInput{}
+	var keys project.ProjectKeys
+	for key, state := range states {
+		if keysRE != nil {
+			if !keysRE.MatchString(string(key)) {
+				continue
+			}
+		} else {
+			if state.CurrentBranch != homeBranch {
+				continue
+			}
+		}
+		if hasUntrackedSet && (state.HasUntracked != runpFlags.hasUntracked) {
+			continue
+		}
+		if hasUncommitedSet && (state.HasUncommitted != runpFlags.hasUncommitted) {
+			continue
+		}
+		if hasGerritSet {
+			hasMsg := false
+			for _, br := range state.Branches {
+				if (state.CurrentBranch == br.Name) && br.HasGerritMessage {
+					hasMsg = true
+					break
+				}
+			}
+			if hasMsg != runpFlags.hasGerritMessage {
+				continue
+			}
+		}
+		mapInputs[key] = &mapInput{
+			ProjectState: state,
+			jirix:        jirix,
+			key:          key,
+		}
+		keys = append(keys, key)
+	}
+
+	total := len(mapInputs)
+	index := 1
+	for _, mi := range mapInputs {
+		mi.index = index
+		mi.total = total
+		index++
+	}
+
+	if runpFlags.verbose {
+		fmt.Fprintf(jirix.Stdout(), "Project Names: %s\n", strings.Join(stateNames(mapInputs), " "))
+		fmt.Fprintf(jirix.Stdout(), "Project Keys: %s\n", strings.Join(stateKeys(mapInputs), " "))
+	}
+
+	reader, err := profilesreader.NewReader(jirix, runpFlags.ProfilesMode, runpFlags.DBFilename)
+	runner := &runner{
+		reader: reader,
+		args:   args,
+	}
+	mr := simplemr.MR{}
+	if runpFlags.interactive {
+		// Run one mapper at a time.
+		mr.NumMappers = 1
+		sort.Sort(keys)
+	}
+	in, out := make(chan *simplemr.Record, len(mapInputs)), make(chan *simplemr.Record, len(mapInputs))
+	sigch := make(chan os.Signal)
+	signal.Notify(sigch, os.Interrupt)
+	go func() { <-sigch; mr.Cancel() }()
+	go mr.Run(in, out, runner, runner)
+	for _, key := range keys {
+		in <- &simplemr.Record{string(key), []interface{}{mapInputs[key]}}
+	}
+	close(in)
+	<-out
+	return mr.Error()
+}
+
+func runRunp(jirix *jiri.X, args []string) error {
+	return runp(jirix, cmdRunP, args)
+}
diff --git a/cmd/jiri/runp_test.go b/cmd/jiri/runp_test.go
new file mode 100644
index 0000000..325bbc8
--- /dev/null
+++ b/cmd/jiri/runp_test.go
@@ -0,0 +1,249 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"flag"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+	"testing"
+
+	"v.io/jiri/gitutil"
+	"v.io/jiri/jiritest"
+	"v.io/jiri/project"
+	"v.io/x/lib/gosh"
+)
+
+var (
+	buildJiriOnce   sync.Once
+	buildJiriBinDir = ""
+)
+
+func buildJiri(t *testing.T) string {
+	buildJiriOnce.Do(func() {
+		binDir, err := ioutil.TempDir("", "")
+		if err != nil {
+			t.Fatal(err)
+		}
+		sh := gosh.NewShell(t)
+		defer sh.Cleanup()
+		gosh.BuildGoPkg(sh, binDir, "v.io/jiri/cmd/jiri", "-o", "jiri")
+		buildJiriBinDir = binDir
+	})
+	return buildJiriBinDir
+}
+
+func addProjects(t *testing.T, fake *jiritest.FakeJiriRoot) []*project.Project {
+	projects := []*project.Project{}
+	for _, name := range []string{"a", "b", "c", "t1", "t2"} {
+		projectPath := "r." + name
+		if err := fake.CreateRemoteProject(projectPath); err != nil {
+			t.Fatalf("%v", err)
+		}
+		p := project.Project{
+			Name:         projectPath,
+			Path:         projectPath,
+			Remote:       fake.Projects[projectPath],
+			RemoteBranch: "master",
+		}
+		if err := fake.AddProject(p); err != nil {
+			t.Fatalf("%v", err)
+		}
+		projects = append(projects, &p)
+	}
+	if err := fake.UpdateUniverse(false); err != nil {
+		t.Fatalf("%v", err)
+	}
+	return projects
+}
+
+func run(sh *gosh.Shell, dir, bin string, args ...string) string {
+	cmd := sh.Cmd(filepath.Join(dir, bin), args...)
+	if testing.Verbose() {
+		cmd.PropagateOutput = true
+	}
+	return strings.TrimSpace(cmd.CombinedOutput())
+}
+
+func TestMain(m *testing.M) {
+	flag.Parse()
+	r := m.Run()
+	if buildJiriBinDir != "" {
+		os.RemoveAll(buildJiriBinDir)
+	}
+	os.Exit(r)
+}
+
+func TestRunP(t *testing.T) {
+	fake, cleanup := jiritest.NewFakeJiriRoot(t)
+	defer cleanup()
+	projects := addProjects(t, fake)
+	dir, sh := buildJiri(t), gosh.NewShell(t)
+
+	if got, want := len(projects), 5; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.Chdir(cwd)
+
+	chdir := func(dir string) {
+		if err := os.Chdir(filepath.Join(fake.X.Root, dir)); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	manifestKey := strings.Replace(string(projects[0].Key()), "r.a", "manifest", -1)
+	keys := []string{manifestKey}
+	for _, p := range projects {
+		keys = append(keys, string(p.Key()))
+	}
+
+	chdir(projects[0].Path)
+
+	got := run(sh, dir, "jiri", "runp", "--show-name-prefix", "-v", "echo")
+	hdr := "Project Names: manifest r.a r.b r.c r.t1 r.t2\n"
+	hdr += "Project Keys: " + strings.Join(keys, " ") + "\n"
+
+	if want := hdr + "manifest: \nr.a: \nr.b: \nr.c: \nr.t1: \nr.t2:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "-v", "--interactive=false", "basename", "$(", "jiri", "project", "info", "-f", "{{.Project.Path}}", ")")
+	if want := hdr + "manifest\nr.a\nr.b\nr.c\nr.t1\nr.t2"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "--interactive=false", "git", "rev-parse", "--abbrev-ref", "HEAD")
+	if want := "master\nmaster\nmaster\nmaster\nmaster\nmaster"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "-interactive=false", "--show-name-prefix=true", "git", "rev-parse", "--abbrev-ref", "HEAD")
+	if want := "manifest: master\nr.a: master\nr.b: master\nr.c: master\nr.t1: master\nr.t2: master"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "--interactive=false", "--show-key-prefix=true", "git", "rev-parse", "--abbrev-ref", "HEAD")
+	if want := strings.Join(keys, ": master\n") + ": master"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	uncollated := run(sh, dir, "jiri", "runp", "--interactive=false", "--collate-stdout=false", "--show-name-prefix=true", "git", "rev-parse", "--abbrev-ref", "HEAD")
+	split := strings.Split(uncollated, "\n")
+	sort.Strings(split)
+	got = strings.TrimSpace(strings.Join(split, "\n"))
+	if want := "manifest: master\nr.a: master\nr.b: master\nr.c: master\nr.t1: master\nr.t2: master"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "--show-name-prefix", "--projects=r.t[12]", "echo")
+	if want := "r.t1: \nr.t2:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	rb := projects[1].Path
+	rc := projects[2].Path
+	t1 := projects[3].Path
+
+	s := fake.X.NewSeq()
+	newfile := func(dir, file string) {
+		testfile := filepath.Join(fake.X.Root, dir, file)
+		_, err := s.Create(testfile)
+		if err != nil {
+			t.Errorf("failed to create %s: %v", testfile, err)
+		}
+	}
+
+	git := func(root, dir string) *gitutil.Git {
+		return gitutil.New(fake.X.NewSeq(), gitutil.RootDirOpt(filepath.Join(fake.X.Root, dir)))
+	}
+
+	newfile(rb, "untracked.go")
+
+	got = run(sh, dir, "jiri", "runp", "--has-untracked", "--show-name-prefix", "echo")
+	if want := "r.b:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "--has-untracked=false", "--show-name-prefix", "echo")
+	if want := "manifest: \nr.a: \nr.c: \nr.t1: \nr.t2:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	newfile(rc, "uncommitted.go")
+
+	if err := git(fake.X.Root, rc).Add("uncommitted.go"); err != nil {
+		t.Error(err)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "--has-uncommitted", "--show-name-prefix", "echo")
+	if want := "r.c:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "--has-uncommitted=false", "--show-name-prefix", "echo")
+	if want := "manifest: \nr.a: \nr.b: \nr.t1: \nr.t2:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	// test ordering of has-<x> flags
+	newfile(rc, "untracked.go")
+	got = run(sh, dir, "jiri", "runp", "--has-untracked", "--has-uncommitted", "--show-name-prefix", "echo")
+	if want := "r.c:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "--has-uncommitted", "--has-untracked", "--show-name-prefix", "echo")
+	if want := "r.c:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	git(fake.X.Root, rb).CreateAndCheckoutBranch("a1")
+	git(fake.X.Root, rb).CreateAndCheckoutBranch("b2")
+	git(fake.X.Root, rc).CreateAndCheckoutBranch("b2")
+	git(fake.X.Root, t1).CreateAndCheckoutBranch("a1")
+
+	chdir(rc)
+
+	// Just the projects with branch b2.
+	got = run(sh, dir, "jiri", "runp", "--show-name-prefix", "echo")
+	if want := "r.b: \nr.c:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	// All projects since --projects takes precendence over branches.
+	got = run(sh, dir, "jiri", "runp", "--projects=.*", "--show-name-prefix", "echo")
+	if want := "manifest: \nr.a: \nr.b: \nr.c: \nr.t1: \nr.t2:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	if err := s.MkdirAll(filepath.Join(fake.X.Root, rb, ".jiri", "a1"), os.FileMode(0755)).Done(); err != nil {
+		t.Fatal(err)
+	}
+	newfile(rb, filepath.Join(".jiri", "a1", ".gerrit_commit_message"))
+
+	git(fake.X.Root, rb).CheckoutBranch("a1")
+	git(fake.X.Root, t1).CheckoutBranch("a1")
+	chdir(t1)
+
+	got = run(sh, dir, "jiri", "runp", "--has-gerrit-message", "--show-name-prefix", "echo")
+	if want := "r.b:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+	got = run(sh, dir, "jiri", "runp", "--has-gerrit-message=false", "--show-name-prefix", "echo")
+	if want := "r.t1:"; got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+
+}
diff --git a/project/.api b/project/.api
index 325cd3e..3fb025f 100644
--- a/project/.api
+++ b/project/.api
@@ -7,6 +7,7 @@
 pkg project, func CreateSnapshot(*jiri.X, string, string) error
 pkg project, func CurrentProjectKey(*jiri.X) (ProjectKey, error)
 pkg project, func DataDirPath(*jiri.X, string) (string, error)
+pkg project, func GetProjectState(*jiri.X, ProjectKey, bool) (*ProjectState, error)
 pkg project, func GetProjectStates(*jiri.X, bool) (map[ProjectKey]*ProjectState, error)
 pkg project, func InstallTools(*jiri.X, string) error
 pkg project, func LoadManifest(*jiri.X) (Projects, Tools, error)
diff --git a/project/state.go b/project/state.go
index deaf766..ea6a23a 100644
--- a/project/state.go
+++ b/project/state.go
@@ -5,6 +5,7 @@
 package project
 
 import (
+	"fmt"
 	"path/filepath"
 
 	"v.io/jiri"
@@ -94,3 +95,21 @@
 	}
 	return states, nil
 }
+
+func GetProjectState(jirix *jiri.X, key ProjectKey, checkDirty bool) (*ProjectState, error) {
+	projects, err := LocalProjects(jirix, FastScan)
+	if err != nil {
+		return nil, err
+	}
+	sem := make(chan error, 1)
+	for k, project := range projects {
+		if k == key {
+			state := &ProjectState{
+				Project: project,
+			}
+			setProjectState(jirix, state, checkDirty, sem)
+			return state, <-sem
+		}
+	}
+	return nil, fmt.Errorf("failed to find project key %v", key)
+}