[engine] Resolve root directory using git

Instead of assuming the current working directory (overridden by
`--root`) contains a shac.star file, locate the root of the current git
repository (if the current directory is in a git repository) and use the
shac.star file there, also analyzing all files in the git directory
instead of just files in the current working directory.

The `--root` flag is replaced by `-C` since it's not necessarily any
sort of root, instead being more analogous to the `-C` flag to git.

We have to keep around the `--root` flag for backwards compatibility
until references in other repos can be updated.

TODO: update the <files> positional argument to accept directories, to
make it possible to analyze just a single subdirectory (e.g. `shac check
subdir`).

Change-Id: Ic9aef678e2cb518a81ff2f36f3bec6fda969675d
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/914336
Commit-Queue: Oliver Newman <olivernewman@google.com>
Reviewed-by: Marc-Antoine Ruel <maruel@google.com>
diff --git a/internal/cli/base.go b/internal/cli/base.go
index 929e908..4d0108c 100644
--- a/internal/cli/base.go
+++ b/internal/cli/base.go
@@ -23,22 +23,36 @@
 
 type commandBase struct {
 	root      string
+	cwd       string
 	allFiles  bool
 	noRecurse bool
 }
 
 func (c *commandBase) SetFlags(f *flag.FlagSet) {
-	f.StringVar(&c.root, "root", ".", "path to the root of the tree to analyse")
+	f.StringVarP(&c.cwd, "cwd", "C", ".", "directory in which to run shac")
 	f.BoolVar(&c.allFiles, "all", false, "checks all the files instead of guess the upstream to diff against")
 	f.BoolVar(&c.noRecurse, "no-recurse", false, "do not look for shac.star files recursively")
+
+	// TODO(olivernewman): Delete this flag after it's no longer used.
+	f.StringVar(&c.root, "root", ".", "path to the root of the tree to analyse")
+	// MarkHidden instead of MarkDeprecated to prevent warnings from being
+	// emitted.
+	f.MarkHidden("root")
 }
 
 func (c *commandBase) options(files []string) (engine.Options, error) {
 	if c.allFiles && len(files) > 0 {
 		return engine.Options{}, errors.New("--all cannot be set together with positional file arguments")
 	}
+	cwd := c.cwd
+	if c.root != "." {
+		if cwd != "." {
+			return engine.Options{}, errors.New("--root and --cwd cannot both be set")
+		}
+		cwd = c.root
+	}
 	return engine.Options{
-		Root:     c.root,
+		Root:     cwd,
 		AllFiles: c.allFiles,
 		Files:    files,
 		Recurse:  !c.noRecurse,
diff --git a/internal/engine/bench_test.go b/internal/engine/bench_test.go
index 0e8e731..88d0f60 100644
--- a/internal/engine/bench_test.go
+++ b/internal/engine/bench_test.go
@@ -32,8 +32,10 @@
 }
 
 func BenchmarkPrint_Git(b *testing.B) {
+	root := makeGit(b)
+	copyFile(b, root, "testdata/bench/print.star")
 	want := "[//print.star:16] running\n"
-	benchStarlarkPrint(b, "testdata/bench", "print.star", true, want)
+	benchStarlarkPrint(b, root, "print.star", true, want)
 }
 
 func BenchmarkPrint100_Raw(b *testing.B) {
@@ -44,8 +46,10 @@
 }
 
 func BenchmarkPrint100_Git(b *testing.B) {
+	root := makeGit(b)
+	copyFile(b, root, "testdata/bench/print100.star")
 	want := strings.Repeat("[//print100.star:16] running\n", 100)
-	benchStarlarkPrint(b, "testdata/bench", "print100.star", true, want)
+	benchStarlarkPrint(b, root, "print100.star", true, want)
 }
 
 func BenchmarkCtxEmitFinding(b *testing.B) {
diff --git a/internal/engine/env.go b/internal/engine/env.go
index a3d4cd3..f9d93c0 100644
--- a/internal/engine/env.go
+++ b/internal/engine/env.go
@@ -231,7 +231,7 @@
 			}
 		} else if errors.Is(err, fs.ErrNotExist) {
 			// Hide the underlying error for determinism.
-			source.err = errors.New("file not found")
+			source.err = fmt.Errorf("%s not found", sk.relpath)
 		} else {
 			source.err = err
 		}
diff --git a/internal/engine/fix.go b/internal/engine/fix.go
index b8a0a0d..e513c7c 100644
--- a/internal/engine/fix.go
+++ b/internal/engine/fix.go
@@ -51,9 +51,8 @@
 	// Sort for determinism.
 	sort.Strings(orderedFiles)
 
-	for _, f := range orderedFiles {
-		findings := findingsByFile[f]
-		path := filepath.Join(o.Root, f)
+	for _, path := range orderedFiles {
+		findings := findingsByFile[path]
 		numFixed, err := fixFindings(path, findings)
 		if err != nil {
 			return err
@@ -63,7 +62,7 @@
 			noun += "s"
 		}
 		if !quiet {
-			fmt.Fprintf(os.Stderr, "Fixed %d %s in %s\n", numFixed, noun, f)
+			fmt.Fprintf(os.Stderr, "Fixed %d %s in %s\n", numFixed, noun, path)
 		}
 	}
 	return nil
@@ -185,7 +184,7 @@
 		c.mu.Lock()
 		defer c.mu.Unlock()
 		c.findings = append(c.findings, findingToFix{
-			file:        file,
+			file:        filepath.Join(root, filepath.FromSlash(file)),
 			span:        s,
 			replacement: replacements[0],
 		})
diff --git a/internal/engine/git.go b/internal/engine/git.go
new file mode 100644
index 0000000..7128e5b
--- /dev/null
+++ b/internal/engine/git.go
@@ -0,0 +1,103 @@
+// Copyright 2023 The Shac Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package engine
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"sort"
+	"strings"
+	"sync"
+
+	"go.fuchsia.dev/shac-project/shac/internal/execsupport"
+)
+
+// cachedGitEnv should never be accessed directly, only by calling `gitEnv()`.
+var cachedGitEnv []string
+var populateGitEnvOnce sync.Once
+
+func gitEnv() []string {
+	populateGitEnvOnce.Do(func() {
+		// First is for git version before 2.32, the rest are to skip the user and system config.
+		cachedGitEnv = append(os.Environ(),
+			"GIT_CONFIG_NOGLOBAL=true",
+			"GIT_CONFIG_GLOBAL=",
+			"GIT_CONFIG_SYSTEM=",
+			"LANG=C",
+			"GIT_EXTERNAL_DIFF=",
+			"GIT_DIFF_OPTS=",
+		)
+		gitConfig := map[string]string{
+			// Prevents automatic unicode decomposition of filenames. Only has
+			// an effect on macOS.
+			"core.precomposeUnicode": "true",
+		}
+		cachedGitEnv = append(cachedGitEnv, gitConfigEnv(gitConfig)...)
+	})
+	return cachedGitEnv
+}
+
+func runGitCmd(ctx context.Context, dir string, args ...string) (string, error) {
+	args = append([]string{
+		// Don't update the git index during read operations.
+		"--no-optional-locks",
+	}, args...)
+	cmd := exec.CommandContext(ctx, "git", args...)
+	cmd.Dir = dir
+	cmd.Env = gitEnv()
+	b := buffers.get()
+	cmd.Stdout = b
+	cmd.Stderr = b
+	err := execsupport.Run(cmd)
+	// Always make a copy of the output, since it could be persisted. Only reuse
+	// the temporary buffer.
+	out := b.String()
+	buffers.push(b)
+	if err != nil {
+		if errExit := (&exec.ExitError{}); errors.As(err, &errExit) {
+			return "", fmt.Errorf("error running git %s: %w\n%s", strings.Join(args, " "), err, out)
+		}
+		return "", err
+	}
+	return strings.TrimSpace(out), nil
+}
+
+// gitConfigEnv converts a map of key-value git config pairs into corresponding
+// environment variables.
+//
+// See https://git-scm.com/docs/git-config#ENVIRONMENT for details on how git
+// configs are set via environment variables.
+func gitConfigEnv(gitConfig map[string]string) []string {
+	// GIT_CONFIG_COUNT specifies how many key/value env var pairs to look for.
+	res := []string{fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(gitConfig))}
+
+	keys := make([]string, 0, len(gitConfig))
+	for k := range gitConfig {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+
+	for i, k := range keys {
+		// Each config setting is specified by setting a pair of
+		// GIT_CONFIG_KEY_<N> and GIT_CONFIG_VALUE_<N> variables.
+		res = append(res,
+			fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, k),
+			fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, gitConfig[k]))
+	}
+	return res
+}
diff --git a/internal/engine/pkg_test.go b/internal/engine/pkg_test.go
index 8124fbe..bc3db80 100644
--- a/internal/engine/pkg_test.go
+++ b/internal/engine/pkg_test.go
@@ -97,6 +97,10 @@
 			rel = newName
 		}
 		dest := filepath.Join(dstDir, rel)
+		fi, err := os.Stat(path)
+		if err != nil {
+			return err
+		}
 		b, err := os.ReadFile(path)
 		if err != nil {
 			return err
@@ -104,7 +108,7 @@
 		if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil {
 			return err
 		}
-		return os.WriteFile(dest, b, 0o600)
+		return os.WriteFile(dest, b, fi.Mode())
 	})
 	if err != nil {
 		t.Fatal(err)
diff --git a/internal/engine/run.go b/internal/engine/run.go
index bf785a4..400fc5e 100644
--- a/internal/engine/run.go
+++ b/internal/engine/run.go
@@ -22,7 +22,9 @@
 	"errors"
 	"fmt"
 	"io/fs"
+	"log"
 	"os"
+	"os/exec"
 	"path"
 	"path/filepath"
 	"runtime"
@@ -142,7 +144,9 @@
 	// reporting.Get() which returns the right implementation based on the
 	// environment (CI, interactive, etc).
 	Report Report
-	// Root directory. Defaults to the current working directory.
+	// Root overrides the current working directory, making shac behave as if it
+	// was run in the specified directory. It defaults to the current working
+	// directory.
 	Root string
 	// Files lists specific files to analyze.
 	Files []string
@@ -162,11 +166,7 @@
 
 // Run loads a main shac.star file from a root directory and runs it.
 func Run(ctx context.Context, o *Options) error {
-	root := o.Root
-	if root == "" {
-		root = "."
-	}
-	root, err := filepath.Abs(root)
+	root, err := resolveRoot(ctx, o.Root)
 	if err != nil {
 		return err
 	}
@@ -424,6 +424,44 @@
 	return nil
 }
 
+// resolveRoot resolves an appropriate root directory from which to load shac
+// checks and analyze files.
+func resolveRoot(ctx context.Context, dir string) (string, error) {
+	if dir == "" {
+		dir = "."
+	}
+
+	fi, err := os.Stat(dir)
+	if errors.Is(err, fs.ErrNotExist) {
+		return "", fmt.Errorf("no such directory: %s", dir)
+	} else if err != nil {
+		return "", err
+	} else if !fi.IsDir() {
+		return "", fmt.Errorf("not a directory: %s", dir)
+	}
+
+	dir, err = filepath.Abs(dir)
+	if err != nil {
+		return "", err
+	}
+	root, err := runGitCmd(ctx, dir, "rev-parse", "--show-toplevel")
+	if err != nil {
+		if errors.Is(err, exec.ErrNotFound) {
+			log.Printf("git not detected on $PATH")
+			return dir, nil
+		} else if strings.Contains(err.Error(), "not a git repository") {
+			log.Printf("current working directory is not a git repository")
+			return dir, nil
+		}
+		// Any other error is fatal.
+		return "", err
+	}
+	// root will have normal Windows path but git returns a POSIX style path
+	// that may be incorrect. Clean it up.
+	root = strings.ReplaceAll(filepath.Clean(root), string(os.PathSeparator), "/")
+	return root, nil
+}
+
 // shacState represents a parsing state of one shac.star.
 type shacState struct {
 	env          *starlarkEnv
diff --git a/internal/engine/run_test.go b/internal/engine/run_test.go
index 0fe522c..d0663e2 100644
--- a/internal/engine/run_test.go
+++ b/internal/engine/run_test.go
@@ -40,6 +40,7 @@
 
 func TestRun_Fail(t *testing.T) {
 	t.Parallel()
+	scratchDir := t.TempDir()
 	data := []struct {
 		name string
 		o    Options
@@ -71,9 +72,14 @@
 		},
 		{
 			"malformed config file",
-			Options{
-				config: "testdata/config/syntax.textproto",
-			},
+			func() Options {
+				root := t.TempDir()
+				writeFile(t, root, "shac.star", ``)
+				writeFile(t, root, "shac.textproto", "bad")
+				return Options{
+					Root: root,
+				}
+			}(),
 			// The encoding is not deterministic.
 			"...: unknown field: bad",
 		},
@@ -84,7 +90,7 @@
 			},
 			// TODO(olivernewman): This error should be more specific, like "no
 			// shac.star file found".
-			"file not found",
+			"shac.star not found",
 		},
 		{
 			"no shac.star files (recursive)",
@@ -94,6 +100,23 @@
 			},
 			"no shac.star files found",
 		},
+		{
+			"nonexistent directory",
+			Options{
+				Root: "!!!this-is-a-file-that-does-not-exist!!!",
+			},
+			"no such directory: !!!this-is-a-file-that-does-not-exist!!!",
+		},
+		{
+			"not a directory",
+			Options{
+				Root: func() string {
+					writeFile(t, scratchDir, "foo.txt", "")
+					return filepath.Join(scratchDir, "foo.txt")
+				}(),
+			},
+			fmt.Sprintf("not a directory: %s", filepath.Join(scratchDir, "foo.txt")),
+		},
 	}
 	for i := range data {
 		i := i
@@ -117,6 +140,41 @@
 	}
 }
 
+func TestRun_DirOverridden(t *testing.T) {
+	t.Parallel()
+
+	data := []struct {
+		name string
+		// The full error message often includes the directory, hence a function
+		// so it's easier to construct the values.
+		dir  string
+		want string
+	}{
+		{
+			"git-aware",
+			func() string {
+				root := makeGit(t)
+				writeFile(t, root, "shac.star", `print("hello")`)
+
+				// If shac is pointed at a subdirectory of a git repo, it should
+				// discover and run checks defined anywhere in the repo.
+				subdir := filepath.Join(root, "a", "b")
+				mkdirAll(t, subdir)
+				return subdir
+			}(),
+			"[//shac.star:1] hello\n",
+		},
+	}
+
+	for i := range data {
+		i := i
+		t.Run(data[i].name, func(t *testing.T) {
+			t.Parallel()
+			testStarlarkPrint(t, data[i].dir, "", false, data[i].want)
+		})
+	}
+}
+
 func TestRun_SpecificFiles(t *testing.T) {
 	// Not parallelized because it calls os.Chdir.
 
@@ -282,9 +340,7 @@
 
 	root := t.TempDir()
 	subdir := filepath.Join(root, "dir")
-	if err := os.Mkdir(subdir, 0o700); err != nil {
-		t.Fatal(err)
-	}
+	mkdirAll(t, subdir)
 	dirOutsideRoot := t.TempDir()
 
 	data := []struct {
@@ -1402,7 +1458,7 @@
 		},
 		{
 			"load-inexistant.star",
-			"cannot load ./inexistant.star: file not found",
+			"cannot load ./inexistant.star: inexistant.star not found",
 			"  //load-inexistant.star:15:1: in <toplevel>\n",
 		},
 		{
@@ -1945,7 +2001,10 @@
 }
 
 func TestRun_Vendored(t *testing.T) {
-	testStarlarkPrint(t, "testdata/vendored", "shac.star", false, "[//shac.star:17] True\n")
+	t.Parallel()
+	dir := t.TempDir()
+	copyTree(t, dir, "testdata/vendored", nil)
+	testStarlarkPrint(t, dir, "shac.star", false, "[//shac.star:17] True\n")
 }
 
 // Utilities
@@ -1980,10 +2039,13 @@
 			out = append(out, n)
 		}
 	}
-	return p, out
+	dest := t.TempDir()
+	copyTree(t, dest, p, nil)
+	return dest, out
 }
 
 func makeGit(t testing.TB) string {
+	t.Helper()
 	// scm.go requires two commits. Not really worth fixing yet, it's only
 	// annoying in unit tests.
 	root := t.TempDir()
@@ -2003,6 +2065,7 @@
 }
 
 func initGit(t testing.TB, dir string) {
+	t.Helper()
 	runGit(t, dir, "init")
 	runGit(t, dir, "config", "user.email", "test@example.com")
 	runGit(t, dir, "config", "user.name", "engine test")
@@ -2032,20 +2095,28 @@
 }
 
 func writeFile(t testing.TB, root, path, content string) {
+	t.Helper()
 	writeFileBytes(t, root, path, []byte(content), 0o600)
 }
 
 func writeFileBytes(t testing.TB, root, path string, content []byte, perm os.FileMode) {
+	t.Helper()
 	abs := filepath.Join(root, path)
-	if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil {
-		t.Fatal(err)
-	}
+	mkdirAll(t, filepath.Dir(abs))
 	if err := os.WriteFile(abs, content, perm); err != nil {
 		t.Fatal(err)
 	}
 }
 
+func mkdirAll(t testing.TB, dir string) {
+	t.Helper()
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		t.Fatal(err)
+	}
+}
+
 func readFile(t *testing.T, path string) string {
+	t.Helper()
 	b, err := os.ReadFile(path)
 	if err != nil {
 		t.Fatal(err)
diff --git a/internal/engine/runtime_ctx_scm.go b/internal/engine/runtime_ctx_scm.go
index 9c88c00..e2ca780 100644
--- a/internal/engine/runtime_ctx_scm.go
+++ b/internal/engine/runtime_ctx_scm.go
@@ -32,7 +32,6 @@
 	"unsafe"
 
 	"github.com/go-git/go-git/plumbing/format/gitignore"
-	"go.fuchsia.dev/shac-project/shac/internal/execsupport"
 	"go.starlark.net/starlark"
 )
 
@@ -285,8 +284,8 @@
 func getSCM(ctx context.Context, root string, allFiles bool) (scmCheckout, error) {
 	// Flip to POSIX style path.
 	root = strings.ReplaceAll(root, string(os.PathSeparator), "/")
-	g := &gitCheckout{returnAll: allFiles}
-	err := g.init(ctx, root)
+	g := &gitCheckout{returnAll: allFiles, checkoutRoot: root}
+	err := g.init(ctx)
 	if err == nil {
 		if g.checkoutRoot != root {
 			if !strings.HasPrefix(root, g.checkoutRoot) {
@@ -365,7 +364,6 @@
 // gitCheckout represents a git checkout.
 type gitCheckout struct {
 	// Configuration.
-	env       []string
 	returnAll bool
 
 	// Detected environment at initialization.
@@ -378,13 +376,7 @@
 	err error // save error.
 }
 
-func (g *gitCheckout) init(ctx context.Context, root string) error {
-	// Find root.
-	g.checkoutRoot = root
-	g.checkoutRoot = g.run(ctx, "rev-parse", "--show-toplevel")
-	// root will have normal Windows path but git returns a POSIX style path
-	// that may be incorrect. Clean it up.
-	g.checkoutRoot = strings.ReplaceAll(filepath.Clean(g.checkoutRoot), string(os.PathSeparator), "/")
+func (g *gitCheckout) init(ctx context.Context) error {
 	g.head.hash = g.run(ctx, "rev-parse", "HEAD")
 	g.head.ref = g.run(ctx, "rev-parse", "--abbrev-ref=strict", "--symbolic-full-name", "HEAD")
 	if g.err != nil {
@@ -421,69 +413,9 @@
 	if g.err != nil {
 		return ""
 	}
-	args = append([]string{
-		// Don't update the git index during read operations.
-		"--no-optional-locks",
-	}, args...)
-	cmd := exec.CommandContext(ctx, "git", args...)
-	cmd.Dir = g.checkoutRoot
-	if g.env == nil {
-		// First is for git version before 2.32, the rest are to skip the user and system config.
-		g.env = append(os.Environ(),
-			"GIT_CONFIG_NOGLOBAL=true",
-			"GIT_CONFIG_GLOBAL=",
-			"GIT_CONFIG_SYSTEM=",
-			"LANG=C",
-			"GIT_EXTERNAL_DIFF=",
-			"GIT_DIFF_OPTS=",
-		)
-		gitConfig := map[string]string{
-			// Prevents automatic unicode decomposition of filenames. Only has
-			// an effect on macOS.
-			"core.precomposeUnicode": "true",
-		}
-		g.env = append(g.env, gitConfigEnv(gitConfig)...)
-	}
-	cmd.Env = g.env
-	b := buffers.get()
-	cmd.Stdout = b
-	cmd.Stderr = b
-	err := execsupport.Run(cmd)
-	// Always make a copy of the output, since it could be persisted. Only reuse
-	// the temporary buffer.
-	out := b.String()
-	buffers.push(b)
+	res, err := runGitCmd(ctx, g.checkoutRoot, args...)
 	if err != nil {
-		if errExit := (&exec.ExitError{}); errors.As(err, &errExit) {
-			g.err = fmt.Errorf("error running git %s: %w\n%s", strings.Join(args, " "), err, out)
-		} else {
-			g.err = err
-		}
-	}
-	return strings.TrimSpace(out)
-}
-
-// gitConfigEnv converts a map of key-value git config pairs into corresponding
-// environment variables.
-//
-// See https://git-scm.com/docs/git-config#ENVIRONMENT for details on how git
-// configs are set via environment variables.
-func gitConfigEnv(gitConfig map[string]string) []string {
-	// GIT_CONFIG_COUNT specifies how many key/value env var pairs to look for.
-	res := []string{fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(gitConfig))}
-
-	keys := make([]string, 0, len(gitConfig))
-	for k := range gitConfig {
-		keys = append(keys, k)
-	}
-	sort.Strings(keys)
-
-	for i, k := range keys {
-		// Each config setting is specified by setting a pair of
-		// GIT_CONFIG_KEY_<N> and GIT_CONFIG_VALUE_<N> variables.
-		res = append(res,
-			fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, k),
-			fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, gitConfig[k]))
+		g.err = err
 	}
 	return res
 }
diff --git a/internal/engine/testdata/config/syntax.textproto b/internal/engine/testdata/config/syntax.textproto
deleted file mode 100644
index bf97a57..0000000
--- a/internal/engine/testdata/config/syntax.textproto
+++ /dev/null
@@ -1,15 +0,0 @@
-# Copyright 2023 The Shac Authors
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-bad