[project] Support moving projects into subdirectories

Suppose a project located in "third_party/foo" contained a directory
called "src". In this case, changing the project's path from
"third_party/foo" to "third_party/foo/src" would fail because the
destination directory already existed.

However, this move is actually safe - we're just moving the entire
directory down a level. So add a check to handle this special case.

Also add an integration test to make sure it works as intended. To get
the test to reproduce the failure, I needed to update the code that
calls `op.Test()` to actually call `Test()` on all operations in the
batch - previously it was skipping the first operation in the batch, and
so the test wasn't able to reproduce the failure because the test case
produced a batch of size one.

Change-Id: I557bdb436205ae128a2f0976d1f965e4b41c00b6
Reviewed-on: https://fuchsia-review.googlesource.com/c/jiri/+/1348544
Commit-Queue: Oliver Newman <olivernewman@google.com>
Fuchsia-Auto-Submit: Oliver Newman <olivernewman@google.com>
Reviewed-by: Erick Tryzelaar <etryzelaar@google.com>
diff --git a/integrationtests/update_test.go b/integrationtests/update_test.go
index 941cfa3..722df5a 100644
--- a/integrationtests/update_test.go
+++ b/integrationtests/update_test.go
@@ -6,6 +6,7 @@
 
 import (
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -463,3 +464,50 @@
 		t.Errorf("Root project revision incorrect; want %q, got %q. Git log:\n%s", oldRev, newRev, log)
 	}
 }
+
+// Test that it's possible to change the path of a project to a subdirectory of
+// the old path, even if that subdirectory already existed.
+func TestMovingProjectIntoSubdir(t *testing.T) {
+	t.Parallel()
+
+	aRemoteDir := t.TempDir()
+	setupGitRepo(t, aRemoteDir, map[string]any{
+		// project A contains a "src" subdirectory, which shouldn't stop us from
+		// moving project A into the "src" subdirectory of its original
+		// path.
+		"src/foo.txt": "foo\n",
+	})
+
+	bRemoteDir := t.TempDir()
+	setupGitRepo(t, bRemoteDir, map[string]any{
+		"manifest": project.Manifest{
+			Projects: []project.Project{
+				{
+					Name:   "a",
+					Path:   "path_to_a",
+					Remote: aRemoteDir,
+				},
+			},
+		},
+	})
+
+	root := t.TempDir()
+	jiri := jiriInit(t, root)
+	jiri("import", "manifest", bRemoteDir)
+	jiri("update")
+	checkDirContents(t, root, []string{
+		"path_to_a/src/foo.txt",
+	})
+
+	manifestPath := filepath.Join(bRemoteDir, "manifest")
+	manifestContents := readFile(t, manifestPath)
+	// Update project a's path from `path_to_a` to `path_to_a/src`, which is a
+	// directory that already exists (but as a subdirectory of project a).
+	writeFile(t, manifestPath, strings.ReplaceAll(manifestContents, "path_to_a", "path_to_a/src"))
+	runSubprocess(t, bRemoteDir, "git", "commit", "-am", "Update project a's path")
+
+	jiri("update")
+	checkDirContents(t, root, []string{
+		"path_to_a/src/src/foo.txt",
+	})
+}
diff --git a/project/operations.go b/project/operations.go
index 20e1d50..489c509 100644
--- a/project/operations.go
+++ b/project/operations.go
@@ -8,7 +8,6 @@
 	"fmt"
 	"hash/fnv"
 	"io"
-	"io/ioutil"
 	"maps"
 	"os"
 	"path"
@@ -410,21 +409,44 @@
 		}
 		return fmtError(err)
 	}
+
 	if _, err := os.Stat(op.destination); err != nil {
 		if !os.IsNotExist(err) {
 			return fmtError(err)
 		}
-	} else {
-		// Check if the destination is our parent, and if we are the only child.
-		// This allows `jiri` to move repositories up a directory.
-		files, err := ioutil.ReadDir(op.destination)
-		if err != nil {
-			return fmtError(err)
-		}
-		if len(files) > 1 || (len(files) > 0 && filepath.Join(op.destination, files[0].Name()) != op.source) {
+		// The destination doesn't exist so the move should succeed, no further
+		// validation necessary.
+		return nil
+	}
+
+	// If we get here, it means the destination already exists. This may be
+	// acceptable under certain conditions, which we proceed to check for.
+
+	// Check if the project is being moved down a directory, i.e. the
+	// destination is a subdirectory of the source.
+	// TODO(olivernewman): This is only safe as long as the source directory
+	// doesn't contain any nested projects, since any nested projects will also
+	// be moved even if their paths were not updated. In practice it should be
+	// quite rare to change the path of a project that contains nested projects,
+	// so we don't bother handling that case.
+	if strings.HasPrefix(op.destination, op.source+string(filepath.Separator)) {
+		return nil
+	}
+
+	// Check if the project is being moved up a directory.
+	files, err := os.ReadDir(op.destination)
+	if err != nil {
+		return fmtError(err)
+	}
+	for _, file := range files {
+		// If we find any file in the destination directory besides the source
+		// directory, it's not safe to move the source directory to the
+		// destination.
+		if filepath.Join(op.destination, file.Name()) != op.source {
 			return fmt.Errorf("cannot move %q to %q as the destination already exists", op.source, op.destination)
 		}
 	}
+
 	return nil
 }
 
diff --git a/project/project.go b/project/project.go
index c6e730f..d375c02 100644
--- a/project/project.go
+++ b/project/project.go
@@ -2619,12 +2619,14 @@
 		opType := fmt.Sprintf("%T", batchOps[0])
 		batchOps = batchOps[1:]
 		for len(batchOps) > 0 && opType == fmt.Sprintf("%T", batchOps[0]) {
-			if err := batchOps[0].Test(jirix); err != nil {
-				return err
-			}
 			batch = append(batch, batchOps[0])
 			batchOps = batchOps[1:]
 		}
+		for _, op := range batch {
+			if err := op.Test(jirix); err != nil {
+				return err
+			}
+		}
 		if err := runBatch(jirix, params.GC, batch); err != nil {
 			return err
 		}