diff options
| author | 2023-04-28 01:36:44 +0800 | |
|---|---|---|
| committer | 2023-04-28 01:36:44 +0800 | |
| commit | dd84b9d64fb98746a230cd24233ff50a562c39c9 (patch) | |
| tree | b583261ef00b3afe72ec4d6dacb31e57779a6faf /cli/internal/hashing | |
| parent | 0b46fcd72ac34382387b2bcf9095233efbcc52f4 (diff) | |
| download | HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.tar.gz HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.zip | |
Diffstat (limited to 'cli/internal/hashing')
| -rw-r--r-- | cli/internal/hashing/package_deps_hash.go | 461 | ||||
| -rw-r--r-- | cli/internal/hashing/package_deps_hash_test.go | 386 |
2 files changed, 847 insertions, 0 deletions
diff --git a/cli/internal/hashing/package_deps_hash.go b/cli/internal/hashing/package_deps_hash.go new file mode 100644 index 0000000..517cddd --- /dev/null +++ b/cli/internal/hashing/package_deps_hash.go @@ -0,0 +1,461 @@ +package hashing + +import ( + "bufio" + "fmt" + "io" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/pkg/errors" + "github.com/vercel/turbo/cli/internal/encoding/gitoutput" + "github.com/vercel/turbo/cli/internal/fs" + "github.com/vercel/turbo/cli/internal/globby" + "github.com/vercel/turbo/cli/internal/turbopath" + "github.com/vercel/turbo/cli/internal/util" +) + +// PackageDepsOptions are parameters for getting git hashes for a filesystem +type PackageDepsOptions struct { + // PackagePath is the folder path to derive the package dependencies from. This is typically the folder + // containing package.json. If omitted, the default value is the current working directory. + PackagePath turbopath.AnchoredSystemPath + + InputPatterns []string +} + +// GetPackageDeps Builds an object containing git hashes for the files under the specified `packagePath` folder. +func GetPackageDeps(rootPath turbopath.AbsoluteSystemPath, p *PackageDepsOptions) (map[turbopath.AnchoredUnixPath]string, error) { + pkgPath := rootPath.UntypedJoin(p.PackagePath.ToStringDuringMigration()) + // Add all the checked in hashes. + var result map[turbopath.AnchoredUnixPath]string + + // make a copy of the inputPatterns array, because we may be appending to it later. + calculatedInputs := make([]string, len(p.InputPatterns)) + copy(calculatedInputs, p.InputPatterns) + + if len(calculatedInputs) == 0 { + gitLsTreeOutput, err := gitLsTree(pkgPath) + if err != nil { + return nil, fmt.Errorf("could not get git hashes for files in package %s: %w", p.PackagePath, err) + } + result = gitLsTreeOutput + + // Update the checked in hashes with the current repo status + // The paths returned from this call are anchored at the package directory + gitStatusOutput, err := gitStatus(pkgPath, calculatedInputs) + if err != nil { + return nil, fmt.Errorf("Could not get git hashes from git status: %v", err) + } + + var filesToHash []turbopath.AnchoredSystemPath + for filePath, status := range gitStatusOutput { + if status.isDelete() { + delete(result, filePath) + } else { + filesToHash = append(filesToHash, filePath.ToSystemPath()) + } + } + + hashes, err := gitHashObject(turbopath.AbsoluteSystemPathFromUpstream(pkgPath.ToString()), filesToHash) + if err != nil { + return nil, err + } + + // Zip up file paths and hashes together + for filePath, hash := range hashes { + result[filePath] = hash + } + } else { + // Add in package.json and turbo.json to input patterns. Both file paths are relative to pkgPath + // + // - package.json is an input because if the `scripts` in + // the package.json change (i.e. the tasks that turbo executes), we want + // a cache miss, since any existing cache could be invalid. + // - turbo.json because it's the definition of the tasks themselves. The root turbo.json + // is similarly included in the global hash. This file may not exist in the workspace, but + // that is ok, because it will get ignored downstream. + calculatedInputs = append(calculatedInputs, "package.json") + calculatedInputs = append(calculatedInputs, "turbo.json") + + // The input patterns are relative to the package. + // However, we need to change the globbing to be relative to the repo root. + // Prepend the package path to each of the input patterns. + prefixedInputPatterns := []string{} + prefixedExcludePatterns := []string{} + for _, pattern := range calculatedInputs { + if len(pattern) > 0 && pattern[0] == '!' { + rerooted, err := rootPath.PathTo(pkgPath.UntypedJoin(pattern[1:])) + if err != nil { + return nil, err + } + prefixedExcludePatterns = append(prefixedExcludePatterns, rerooted) + } else { + rerooted, err := rootPath.PathTo(pkgPath.UntypedJoin(pattern)) + if err != nil { + return nil, err + } + prefixedInputPatterns = append(prefixedInputPatterns, rerooted) + } + } + absoluteFilesToHash, err := globby.GlobFiles(rootPath.ToStringDuringMigration(), prefixedInputPatterns, prefixedExcludePatterns) + + if err != nil { + return nil, errors.Wrapf(err, "failed to resolve input globs %v", calculatedInputs) + } + + filesToHash := make([]turbopath.AnchoredSystemPath, len(absoluteFilesToHash)) + for i, rawPath := range absoluteFilesToHash { + relativePathString, err := pkgPath.RelativePathString(rawPath) + + if err != nil { + return nil, errors.Wrapf(err, "not relative to package: %v", rawPath) + } + + filesToHash[i] = turbopath.AnchoredSystemPathFromUpstream(relativePathString) + } + + hashes, err := gitHashObject(turbopath.AbsoluteSystemPathFromUpstream(pkgPath.ToStringDuringMigration()), filesToHash) + if err != nil { + return nil, errors.Wrap(err, "failed hashing resolved inputs globs") + } + result = hashes + // Note that in this scenario, we don't need to check git status, we're using hash-object directly which + // hashes the current state, not state at a commit + } + + return result, nil +} + +func manuallyHashFiles(rootPath turbopath.AbsoluteSystemPath, files []turbopath.AnchoredSystemPath) (map[turbopath.AnchoredUnixPath]string, error) { + hashObject := make(map[turbopath.AnchoredUnixPath]string) + for _, file := range files { + hash, err := fs.GitLikeHashFile(file.ToString()) + if err != nil { + return nil, fmt.Errorf("could not hash file %v. \n%w", file.ToString(), err) + } + + hashObject[file.ToUnixPath()] = hash + } + return hashObject, nil +} + +// GetHashableDeps hashes the list of given files, then returns a map of normalized path to hash +// this map is suitable for cross-platform caching. +func GetHashableDeps(rootPath turbopath.AbsoluteSystemPath, files []turbopath.AbsoluteSystemPath) (map[turbopath.AnchoredUnixPath]string, error) { + output := make([]turbopath.AnchoredSystemPath, len(files)) + convertedRootPath := turbopath.AbsoluteSystemPathFromUpstream(rootPath.ToString()) + + for index, file := range files { + anchoredSystemPath, err := file.RelativeTo(convertedRootPath) + if err != nil { + return nil, err + } + output[index] = anchoredSystemPath + } + hashObject, err := gitHashObject(convertedRootPath, output) + if err != nil { + manuallyHashedObject, err := manuallyHashFiles(convertedRootPath, output) + if err != nil { + return nil, err + } + hashObject = manuallyHashedObject + } + + return hashObject, nil +} + +// gitHashObject returns a map of paths to their SHA hashes calculated by passing the paths to `git hash-object`. +// `git hash-object` expects paths to use Unix separators, even on Windows. +// +// Note: paths of files to hash passed to `git hash-object` are processed as relative to the given anchor. +// For that reason we convert all input paths and make them relative to the anchor prior to passing them +// to `git hash-object`. +func gitHashObject(anchor turbopath.AbsoluteSystemPath, filesToHash []turbopath.AnchoredSystemPath) (map[turbopath.AnchoredUnixPath]string, error) { + fileCount := len(filesToHash) + output := make(map[turbopath.AnchoredUnixPath]string, fileCount) + + if fileCount > 0 { + cmd := exec.Command( + "git", // Using `git` from $PATH, + "hash-object", // hash a file, + "--stdin-paths", // using a list of newline-separated paths from stdin. + ) + cmd.Dir = anchor.ToString() // Start at this directory. + + // The functionality for gitHashObject is different enough that it isn't reasonable to + // generalize the behavior for `runGitCmd`. In fact, it doesn't even use the `gitoutput` + // encoding library, instead relying on its own separate `bufio.Scanner`. + + // We're going to send the list of files in via `stdin`, so we grab that pipe. + // This prevents a huge number of encoding issues and shell compatibility issues + // before they even start. + stdinPipe, stdinPipeError := cmd.StdinPipe() + if stdinPipeError != nil { + return nil, stdinPipeError + } + + // Kick the processing off in a goroutine so while that is doing its thing we can go ahead + // and wire up the consumer of `stdout`. + go func() { + defer util.CloseAndIgnoreError(stdinPipe) + + // `git hash-object` understands all relative paths to be relative to the repository. + // This function's result needs to be relative to `rootPath`. + // We convert all files to absolute paths and assume that they will be inside of the repository. + for _, file := range filesToHash { + converted := file.RestoreAnchor(anchor) + + // `git hash-object` expects paths to use Unix separators, even on Windows. + // `git hash-object` expects paths to be one per line so we must escape newlines. + // In order to understand the escapes, the path must be quoted. + // In order to quote the path, the quotes in the path must be escaped. + // Other than that, we just write everything with full Unicode. + stringPath := converted.ToString() + toSlashed := filepath.ToSlash(stringPath) + escapedNewLines := strings.ReplaceAll(toSlashed, "\n", "\\n") + escapedQuotes := strings.ReplaceAll(escapedNewLines, "\"", "\\\"") + prepared := fmt.Sprintf("\"%s\"\n", escapedQuotes) + _, err := io.WriteString(stdinPipe, prepared) + if err != nil { + return + } + } + }() + + // This gives us an io.ReadCloser so that we never have to read the entire input in + // at a single time. It is doing stream processing instead of string processing. + stdoutPipe, stdoutPipeError := cmd.StdoutPipe() + if stdoutPipeError != nil { + return nil, fmt.Errorf("failed to read `git hash-object`: %w", stdoutPipeError) + } + + startError := cmd.Start() + if startError != nil { + return nil, fmt.Errorf("failed to read `git hash-object`: %w", startError) + } + + // The output of `git hash-object` is a 40-character SHA per input, then a newline. + // We need to track the SHA that corresponds to the input file path. + index := 0 + hashes := make([]string, len(filesToHash)) + scanner := bufio.NewScanner(stdoutPipe) + + // Read the output line-by-line (which is our separator) until exhausted. + for scanner.Scan() { + bytes := scanner.Bytes() + + scanError := scanner.Err() + if scanError != nil { + return nil, fmt.Errorf("failed to read `git hash-object`: %w", scanError) + } + + hashError := gitoutput.CheckObjectName(bytes) + if hashError != nil { + return nil, fmt.Errorf("failed to read `git hash-object`: %s", "invalid hash received") + } + + // Worked, save it off. + hashes[index] = string(bytes) + index++ + } + + // Waits until stdout is closed before proceeding. + waitErr := cmd.Wait() + if waitErr != nil { + return nil, fmt.Errorf("failed to read `git hash-object`: %w", waitErr) + } + + // Make sure we end up with a matching number of files and hashes. + hashCount := len(hashes) + if fileCount != hashCount { + return nil, fmt.Errorf("failed to read `git hash-object`: %d files %d hashes", fileCount, hashCount) + } + + // The API of this method specifies that we return a `map[turbopath.AnchoredUnixPath]string`. + for i, hash := range hashes { + filePath := filesToHash[i] + output[filePath.ToUnixPath()] = hash + } + } + + return output, nil +} + +// runGitCommand provides boilerplate command handling for `ls-tree`, `ls-files`, and `status` +// Rather than doing string processing, it does stream processing of `stdout`. +func runGitCommand(cmd *exec.Cmd, commandName string, handler func(io.Reader) *gitoutput.Reader) ([][]string, error) { + stdoutPipe, pipeError := cmd.StdoutPipe() + if pipeError != nil { + return nil, fmt.Errorf("failed to read `git %s`: %w", commandName, pipeError) + } + + startError := cmd.Start() + if startError != nil { + return nil, fmt.Errorf("failed to read `git %s`: %w", commandName, startError) + } + + reader := handler(stdoutPipe) + entries, readErr := reader.ReadAll() + if readErr != nil { + return nil, fmt.Errorf("failed to read `git %s`: %w", commandName, readErr) + } + + waitErr := cmd.Wait() + if waitErr != nil { + return nil, fmt.Errorf("failed to read `git %s`: %w", commandName, waitErr) + } + + return entries, nil +} + +// gitLsTree returns a map of paths to their SHA hashes starting at a particular directory +// that are present in the `git` index at a particular revision. +func gitLsTree(rootPath turbopath.AbsoluteSystemPath) (map[turbopath.AnchoredUnixPath]string, error) { + cmd := exec.Command( + "git", // Using `git` from $PATH, + "ls-tree", // list the contents of the git index, + "-r", // recursively, + "-z", // with each file path relative to the invocation directory and \000-terminated, + "HEAD", // at this specified version. + ) + cmd.Dir = rootPath.ToString() // Include files only from this directory. + + entries, err := runGitCommand(cmd, "ls-tree", gitoutput.NewLSTreeReader) + if err != nil { + return nil, err + } + + output := make(map[turbopath.AnchoredUnixPath]string, len(entries)) + + for _, entry := range entries { + lsTreeEntry := gitoutput.LsTreeEntry(entry) + output[turbopath.AnchoredUnixPathFromUpstream(lsTreeEntry.GetField(gitoutput.Path))] = lsTreeEntry[2] + } + + return output, nil +} + +// getTraversePath gets the distance of the current working directory to the repository root. +// This is used to convert repo-relative paths to cwd-relative paths. +// +// `git rev-parse --show-cdup` always returns Unix paths, even on Windows. +func getTraversePath(rootPath turbopath.AbsoluteSystemPath) (turbopath.RelativeUnixPath, error) { + cmd := exec.Command("git", "rev-parse", "--show-cdup") + cmd.Dir = rootPath.ToString() + + traversePath, err := cmd.Output() + if err != nil { + return "", err + } + + trimmedTraversePath := strings.TrimSuffix(string(traversePath), "\n") + + return turbopath.RelativeUnixPathFromUpstream(trimmedTraversePath), nil +} + +// Don't shell out if we already know where you are in the repository. +// `memoize` is a good candidate for generics. +func memoizeGetTraversePath() func(turbopath.AbsoluteSystemPath) (turbopath.RelativeUnixPath, error) { + cacheMutex := &sync.RWMutex{} + cachedResult := map[turbopath.AbsoluteSystemPath]turbopath.RelativeUnixPath{} + cachedError := map[turbopath.AbsoluteSystemPath]error{} + + return func(rootPath turbopath.AbsoluteSystemPath) (turbopath.RelativeUnixPath, error) { + cacheMutex.RLock() + result, resultExists := cachedResult[rootPath] + err, errExists := cachedError[rootPath] + cacheMutex.RUnlock() + + if resultExists && errExists { + return result, err + } + + invokedResult, invokedErr := getTraversePath(rootPath) + cacheMutex.Lock() + cachedResult[rootPath] = invokedResult + cachedError[rootPath] = invokedErr + cacheMutex.Unlock() + + return invokedResult, invokedErr + } +} + +var memoizedGetTraversePath = memoizeGetTraversePath() + +// statusCode represents the two-letter status code from `git status` with two "named" fields, x & y. +// They have different meanings based upon the actual state of the working tree. Using x & y maps +// to upstream behavior. +type statusCode struct { + x string + y string +} + +func (s statusCode) isDelete() bool { + return s.x == "D" || s.y == "D" +} + +// gitStatus returns a map of paths to their `git` status code. This can be used to identify what should +// be done with files that do not currently match what is in the index. +// +// Note: `git status -z`'s relative path results are relative to the repository's location. +// We need to calculate where the repository's location is in order to determine what the full path is +// before we can return those paths relative to the calling directory, normalizing to the behavior of +// `ls-files` and `ls-tree`. +func gitStatus(rootPath turbopath.AbsoluteSystemPath, patterns []string) (map[turbopath.AnchoredUnixPath]statusCode, error) { + cmd := exec.Command( + "git", // Using `git` from $PATH, + "status", // tell me about the status of the working tree, + "--untracked-files", // including information about untracked files, + "--no-renames", // do not detect renames, + "-z", // with each file path relative to the repository root and \000-terminated, + "--", // and any additional argument you see is a path, promise. + ) + if len(patterns) == 0 { + cmd.Args = append(cmd.Args, ".") // Operate in the current directory instead of the root of the working tree. + } else { + // FIXME: Globbing is using `git`'s globbing rules which are not consistent with `doublestar``. + cmd.Args = append(cmd.Args, patterns...) // Pass in input patterns as arguments. + } + cmd.Dir = rootPath.ToString() // Include files only from this directory. + + entries, err := runGitCommand(cmd, "status", gitoutput.NewStatusReader) + if err != nil { + return nil, err + } + + output := make(map[turbopath.AnchoredUnixPath]statusCode, len(entries)) + convertedRootPath := turbopath.AbsoluteSystemPathFromUpstream(rootPath.ToString()) + + traversePath, err := memoizedGetTraversePath(convertedRootPath) + if err != nil { + return nil, err + } + + for _, entry := range entries { + statusEntry := gitoutput.StatusEntry(entry) + // Anchored at repository. + pathFromStatus := turbopath.AnchoredUnixPathFromUpstream(statusEntry.GetField(gitoutput.Path)) + var outputPath turbopath.AnchoredUnixPath + + if len(traversePath) > 0 { + repositoryPath := convertedRootPath.Join(traversePath.ToSystemPath()) + fileFullPath := pathFromStatus.ToSystemPath().RestoreAnchor(repositoryPath) + + relativePath, err := fileFullPath.RelativeTo(convertedRootPath) + if err != nil { + return nil, err + } + + outputPath = relativePath.ToUnixPath() + } else { + outputPath = pathFromStatus + } + + output[outputPath] = statusCode{x: statusEntry.GetField(gitoutput.StatusX), y: statusEntry.GetField(gitoutput.StatusY)} + } + + return output, nil +} diff --git a/cli/internal/hashing/package_deps_hash_test.go b/cli/internal/hashing/package_deps_hash_test.go new file mode 100644 index 0000000..8f68d38 --- /dev/null +++ b/cli/internal/hashing/package_deps_hash_test.go @@ -0,0 +1,386 @@ +package hashing + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/vercel/turbo/cli/internal/fs" + "github.com/vercel/turbo/cli/internal/turbopath" + "gotest.tools/v3/assert" +) + +func getFixture(id int) turbopath.AbsoluteSystemPath { + cwd, _ := os.Getwd() + root := turbopath.AbsoluteSystemPath(filepath.VolumeName(cwd) + string(os.PathSeparator)) + checking := turbopath.AbsoluteSystemPath(cwd) + + for checking != root { + fixtureDirectory := checking.Join("fixtures") + _, err := os.Stat(fixtureDirectory.ToString()) + if !errors.Is(err, os.ErrNotExist) { + // Found the fixture directory! + files, _ := os.ReadDir(fixtureDirectory.ToString()) + + // Grab the specified fixture. + for _, file := range files { + fileName := turbopath.RelativeSystemPath(file.Name()) + if strings.Index(fileName.ToString(), fmt.Sprintf("%02d-", id)) == 0 { + return turbopath.AbsoluteSystemPath(fixtureDirectory.Join(fileName)) + } + } + } + checking = checking.Join("..") + } + + panic("fixtures not found!") +} + +func TestSpecialCharacters(t *testing.T) { + if runtime.GOOS == "windows" { + return + } + + fixturePath := getFixture(1) + newlinePath := turbopath.AnchoredUnixPath("new\nline").ToSystemPath() + quotePath := turbopath.AnchoredUnixPath("\"quote\"").ToSystemPath() + newline := newlinePath.RestoreAnchor(fixturePath) + quote := quotePath.RestoreAnchor(fixturePath) + + // Setup + one := os.WriteFile(newline.ToString(), []byte{}, 0644) + two := os.WriteFile(quote.ToString(), []byte{}, 0644) + + // Cleanup + defer func() { + one := os.Remove(newline.ToString()) + two := os.Remove(quote.ToString()) + + if one != nil || two != nil { + return + } + }() + + // Setup error check + if one != nil || two != nil { + return + } + + tests := []struct { + name string + rootPath turbopath.AbsoluteSystemPath + filesToHash []turbopath.AnchoredSystemPath + want map[turbopath.AnchoredUnixPath]string + wantErr bool + }{ + { + name: "Quotes", + rootPath: fixturePath, + filesToHash: []turbopath.AnchoredSystemPath{ + quotePath, + }, + want: map[turbopath.AnchoredUnixPath]string{ + quotePath.ToUnixPath(): "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + }, + }, + { + name: "Newlines", + rootPath: fixturePath, + filesToHash: []turbopath.AnchoredSystemPath{ + newlinePath, + }, + want: map[turbopath.AnchoredUnixPath]string{ + newlinePath.ToUnixPath(): "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := gitHashObject(tt.rootPath, tt.filesToHash) + if (err != nil) != tt.wantErr { + t.Errorf("gitHashObject() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("gitHashObject() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_gitHashObject(t *testing.T) { + fixturePath := getFixture(1) + traversePath, err := getTraversePath(fixturePath) + if err != nil { + return + } + + tests := []struct { + name string + rootPath turbopath.AbsoluteSystemPath + filesToHash []turbopath.AnchoredSystemPath + want map[turbopath.AnchoredUnixPath]string + wantErr bool + }{ + { + name: "No paths", + rootPath: fixturePath, + filesToHash: []turbopath.AnchoredSystemPath{}, + want: map[turbopath.AnchoredUnixPath]string{}, + }, + { + name: "Absolute paths come back relative to rootPath", + rootPath: fixturePath.Join("child"), + filesToHash: []turbopath.AnchoredSystemPath{ + turbopath.AnchoredUnixPath("../root.json").ToSystemPath(), + turbopath.AnchoredUnixPath("child.json").ToSystemPath(), + turbopath.AnchoredUnixPath("grandchild/grandchild.json").ToSystemPath(), + }, + want: map[turbopath.AnchoredUnixPath]string{ + "../root.json": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "child.json": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "grandchild/grandchild.json": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + }, + }, + { + name: "Traverse outside of the repo", + rootPath: fixturePath.Join(traversePath.ToSystemPath(), ".."), + filesToHash: []turbopath.AnchoredSystemPath{ + turbopath.AnchoredUnixPath("null.json").ToSystemPath(), + }, + want: nil, + wantErr: true, + }, + { + name: "Nonexistent file", + rootPath: fixturePath, + filesToHash: []turbopath.AnchoredSystemPath{ + turbopath.AnchoredUnixPath("nonexistent.json").ToSystemPath(), + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := gitHashObject(tt.rootPath, tt.filesToHash) + if (err != nil) != tt.wantErr { + t.Errorf("gitHashObject() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("gitHashObject() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getTraversePath(t *testing.T) { + fixturePath := getFixture(1) + + tests := []struct { + name string + rootPath turbopath.AbsoluteSystemPath + want turbopath.RelativeUnixPath + wantErr bool + }{ + { + name: "From fixture location", + rootPath: fixturePath, + want: turbopath.RelativeUnixPath("../../../"), + wantErr: false, + }, + { + name: "Traverse out of git repo", + rootPath: fixturePath.UntypedJoin("..", "..", "..", ".."), + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getTraversePath(tt.rootPath) + if (err != nil) != tt.wantErr { + t.Errorf("getTraversePath() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getTraversePath() = %v, want %v", got, tt.want) + } + }) + } +} + +func requireGitCmd(t *testing.T, repoRoot turbopath.AbsoluteSystemPath, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repoRoot.ToString() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git commit failed: %v %v", err, string(out)) + } +} + +func TestGetPackageDeps(t *testing.T) { + // Directory structure: + // <root>/ + // new-root-file <- new file not added to git + // my-pkg/ + // committed-file + // deleted-file + // uncommitted-file <- new file not added to git + // dir/ + // nested-file + + repoRoot := fs.AbsoluteSystemPathFromUpstream(t.TempDir()) + myPkgDir := repoRoot.UntypedJoin("my-pkg") + + // create the dir first + err := myPkgDir.MkdirAll(0775) + assert.NilError(t, err, "CreateDir") + + // create file 1 + committedFilePath := myPkgDir.UntypedJoin("committed-file") + err = committedFilePath.WriteFile([]byte("committed bytes"), 0644) + assert.NilError(t, err, "WriteFile") + + // create file 2 + deletedFilePath := myPkgDir.UntypedJoin("deleted-file") + err = deletedFilePath.WriteFile([]byte("delete-me"), 0644) + assert.NilError(t, err, "WriteFile") + + // create file 3 + nestedPath := myPkgDir.UntypedJoin("dir", "nested-file") + assert.NilError(t, nestedPath.EnsureDir(), "EnsureDir") + assert.NilError(t, nestedPath.WriteFile([]byte("nested"), 0644), "WriteFile") + + // create a package.json + packageJSONPath := myPkgDir.UntypedJoin("package.json") + err = packageJSONPath.WriteFile([]byte("{}"), 0644) + assert.NilError(t, err, "WriteFile") + + // set up git repo and commit all + requireGitCmd(t, repoRoot, "init", ".") + requireGitCmd(t, repoRoot, "config", "--local", "user.name", "test") + requireGitCmd(t, repoRoot, "config", "--local", "user.email", "test@example.com") + requireGitCmd(t, repoRoot, "add", ".") + requireGitCmd(t, repoRoot, "commit", "-m", "foo") + + // remove a file + err = deletedFilePath.Remove() + assert.NilError(t, err, "Remove") + + // create another untracked file in git + uncommittedFilePath := myPkgDir.UntypedJoin("uncommitted-file") + err = uncommittedFilePath.WriteFile([]byte("uncommitted bytes"), 0644) + assert.NilError(t, err, "WriteFile") + + // create an untracked file in git up a level + rootFilePath := repoRoot.UntypedJoin("new-root-file") + err = rootFilePath.WriteFile([]byte("new-root bytes"), 0644) + assert.NilError(t, err, "WriteFile") + + tests := []struct { + opts *PackageDepsOptions + expected map[turbopath.AnchoredUnixPath]string + }{ + // base case. when inputs aren't specified, all files hashes are computed + { + opts: &PackageDepsOptions{ + PackagePath: "my-pkg", + }, + expected: map[turbopath.AnchoredUnixPath]string{ + "committed-file": "3a29e62ea9ba15c4a4009d1f605d391cdd262033", + "uncommitted-file": "4e56ad89387e6379e4e91ddfe9872cf6a72c9976", + "package.json": "9e26dfeeb6e641a33dae4961196235bdb965b21b", + "dir/nested-file": "bfe53d766e64d78f80050b73cd1c88095bc70abb", + }, + }, + // with inputs, only the specified inputs are hashed + { + opts: &PackageDepsOptions{ + PackagePath: "my-pkg", + InputPatterns: []string{"uncommitted-file"}, + }, + expected: map[turbopath.AnchoredUnixPath]string{ + "package.json": "9e26dfeeb6e641a33dae4961196235bdb965b21b", + "uncommitted-file": "4e56ad89387e6379e4e91ddfe9872cf6a72c9976", + }, + }, + // inputs with glob pattern also works + { + opts: &PackageDepsOptions{ + PackagePath: "my-pkg", + InputPatterns: []string{"**/*-file"}, + }, + expected: map[turbopath.AnchoredUnixPath]string{ + "committed-file": "3a29e62ea9ba15c4a4009d1f605d391cdd262033", + "uncommitted-file": "4e56ad89387e6379e4e91ddfe9872cf6a72c9976", + "package.json": "9e26dfeeb6e641a33dae4961196235bdb965b21b", + "dir/nested-file": "bfe53d766e64d78f80050b73cd1c88095bc70abb", + }, + }, + // inputs with traversal work + { + opts: &PackageDepsOptions{ + PackagePath: "my-pkg", + InputPatterns: []string{"../**/*-file"}, + }, + expected: map[turbopath.AnchoredUnixPath]string{ + "../new-root-file": "8906ddcdd634706188bd8ef1c98ac07b9be3425e", + "committed-file": "3a29e62ea9ba15c4a4009d1f605d391cdd262033", + "uncommitted-file": "4e56ad89387e6379e4e91ddfe9872cf6a72c9976", + "package.json": "9e26dfeeb6e641a33dae4961196235bdb965b21b", + "dir/nested-file": "bfe53d766e64d78f80050b73cd1c88095bc70abb", + }, + }, + // inputs with another glob pattern works + { + opts: &PackageDepsOptions{ + PackagePath: "my-pkg", + InputPatterns: []string{"**/{uncommitted,committed}-file"}, + }, + expected: map[turbopath.AnchoredUnixPath]string{ + "committed-file": "3a29e62ea9ba15c4a4009d1f605d391cdd262033", + "package.json": "9e26dfeeb6e641a33dae4961196235bdb965b21b", + "uncommitted-file": "4e56ad89387e6379e4e91ddfe9872cf6a72c9976", + }, + }, + // inputs with another glob pattern + traversal work + { + opts: &PackageDepsOptions{ + PackagePath: "my-pkg", + InputPatterns: []string{"../**/{new-root,uncommitted,committed}-file"}, + }, + expected: map[turbopath.AnchoredUnixPath]string{ + "../new-root-file": "8906ddcdd634706188bd8ef1c98ac07b9be3425e", + "committed-file": "3a29e62ea9ba15c4a4009d1f605d391cdd262033", + "package.json": "9e26dfeeb6e641a33dae4961196235bdb965b21b", + "uncommitted-file": "4e56ad89387e6379e4e91ddfe9872cf6a72c9976", + }, + }, + } + for _, tt := range tests { + got, err := GetPackageDeps(repoRoot, tt.opts) + if err != nil { + t.Errorf("GetPackageDeps got error %v", err) + continue + } + assert.DeepEqual(t, got, tt.expected) + } +} + +func Test_memoizedGetTraversePath(t *testing.T) { + fixturePath := getFixture(1) + + gotOne, _ := memoizedGetTraversePath(fixturePath) + gotTwo, _ := memoizedGetTraversePath(fixturePath) + + assert.Check(t, gotOne == gotTwo, "The strings are identical.") +} |
