diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7c0b937..8d77ebb 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -108,7 +108,10 @@ and CLI publication tests. Use the offline environment specified in Stage 10. ## Stage 2: Add optional checkout-root discovery with precise failure handling -**Status:** pending. +**Status:** complete. `go test ./internal/git` passes offline, including real +checkout, worktree, alias, standalone, missing-Git, diagnostic, and failure +cases. The public Go-only preparation composition check is deferred to Stage 3, +where preparation adopts discovery. **Files:** `internal/git/source.go` or a focused companion file, Git tests, and `internal/process/process.go` only if an existing invocation capability proves diff --git a/internal/git/discovery_test.go b/internal/git/discovery_test.go new file mode 100644 index 0000000..ec986c3 --- /dev/null +++ b/internal/git/discovery_test.go @@ -0,0 +1,111 @@ +package git + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestDiscoverRepositoryRoot(t *testing.T) { + repo := newRemoteFixture(t) + child := filepath.Join(repo.work, "child") + if err := os.Mkdir(child, 0755); err != nil { + t.Fatal(err) + } + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(repo.work, alias); err != nil { + t.Fatal(err) + } + worktree := filepath.Join(t.TempDir(), "worktree") + gitRun(t, repo.work, "worktree", "add", "--detach", worktree) + for _, tc := range []struct{ dir, want string }{{repo.work, repo.work}, {child, repo.work}, {alias, repo.work}, {worktree, worktree}} { + root, found, err := DiscoverRepositoryRoot(context.Background(), tc.dir) + want, e := filepath.EvalSymlinks(tc.want) + if e != nil { + t.Fatal(e) + } + if err != nil || !found || root != want { + t.Fatalf("Discover(%s) = %q, %t, %v; want %q", tc.dir, root, found, err, want) + } + } + standalone := t.TempDir() + if root, found, err := DiscoverRepositoryRoot(context.Background(), standalone); err != nil || found || root != "" { + t.Fatalf("standalone = %q, %t, %v", root, found, err) + } + if _, err := RepositoryRoot(context.Background(), standalone); err == nil { + t.Fatal("strict lookup accepted standalone") + } + broken := t.TempDir() + if err := os.WriteFile(filepath.Join(broken, ".git"), []byte("gitdir: missing\n"), 0600); err != nil { + t.Fatal(err) + } + if _, found, err := DiscoverRepositoryRoot(context.Background(), broken); err == nil || found { + t.Fatalf("broken marker: found %t, error %v", found, err) + } + if _, found, err := DiscoverRepositoryRoot(context.Background(), filepath.Join(standalone, "missing")); err == nil || found { + t.Fatalf("missing directory: found %t, error %v", found, err) + } +} + +func TestNoRepositoryDiagnostic(t *testing.T) { + for _, tc := range []struct { + message string + want bool + }{ + {"fatal: not a git repository (or any of the parent directories): .git\n", true}, + {"fatal: not a git repository (or any parent up to mount point /some path)\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).\n", true}, + {"fatal: not a git repository: '/bad'", false}, + {"fatal: detected dubious ownership in repository", false}, + } { + if got := noRepositoryDiagnostic(tc.message); got != tc.want { + t.Errorf("diagnostic %q = %t", tc.message, got) + } + } +} + +func TestDiscoverRepositoryRootFailures(t *testing.T) { + dir := t.TempDir() + bin := t.TempDir() + script := "#!/bin/sh\nprintf '%s\\n' 'fatal: detected dubious ownership in repository' >&2\nexit 128\n" + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + _, found, err := DiscoverRepositoryRoot(context.Background(), dir) + var exitErr *exec.ExitError + if found || !errors.As(err, &exitErr) || exitErr.ExitCode() != 128 || !strings.Contains(err.Error(), "dubious ownership") { + t.Fatalf("found %t, error %v", found, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, _, err := DiscoverRepositoryRoot(ctx, dir); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation = %v", err) + } +} + +func TestDiscoverRepositoryRootWithoutGit(t *testing.T) { + repo := newRemoteFixture(t) + standalone := t.TempDir() + t.Setenv("PATH", t.TempDir()) + if _, found, err := DiscoverRepositoryRoot(context.Background(), standalone); found || err != nil { + t.Fatalf("standalone: found %t, error %v", found, err) + } + if _, found, err := DiscoverRepositoryRoot(context.Background(), repo.work); found || !errors.Is(err, exec.ErrNotFound) { + t.Fatalf("checkout: found %t, error %v", found, err) + } + broken := t.TempDir() + if err := os.WriteFile(filepath.Join(broken, ".git"), []byte("broken"), 0600); err != nil { + t.Fatal(err) + } + if _, found, err := DiscoverRepositoryRoot(context.Background(), broken); found || !errors.Is(err, exec.ErrNotFound) { + t.Fatalf("broken marker: found %t, error %v", found, err) + } + t.Setenv("GIT_DIR", "invalid") + if _, found, err := DiscoverRepositoryRoot(context.Background(), standalone); found || !errors.Is(err, exec.ErrNotFound) { + t.Fatalf("explicit context: found %t, error %v", found, err) + } +} diff --git a/internal/git/source.go b/internal/git/source.go index 7cbb44f..14d2362 100644 --- a/internal/git/source.go +++ b/internal/git/source.go @@ -3,8 +3,11 @@ package git import ( "bytes" "context" + "errors" "fmt" "io" + "os" + "os/exec" "path" "path/filepath" "strconv" @@ -41,6 +44,10 @@ func RepositoryRoot(ctx context.Context, directory string) (string, error) { if err != nil { return "", fmt.Errorf("resolve Git repository root: %w", err) } + return normalizeRepositoryRoot(root) +} + +func normalizeRepositoryRoot(root string) (string, error) { root = strings.TrimSuffix(root, "\n") resolved, err := filepath.EvalSymlinks(root) if err != nil { @@ -49,6 +56,84 @@ func RepositoryRoot(ctx context.Context, directory string) (string, error) { return resolved, nil } +// DiscoverRepositoryRoot finds a containing checkout or confirms standalone use. +func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) { + if directory == "" { + directory = "." + } + var stdout, stderr strings.Builder + err := process.Run(ctx, process.Invocation{ + Args: []string{"git", "rev-parse", "--show-toplevel"}, Dir: directory, + Env: map[string]string{"LC_ALL": "C"}, Stdout: &stdout, Stderr: &stderr, + }) + if err == nil { + root, err := normalizeRepositoryRoot(stdout.String()) + return root, err == nil, err + } + if errors.Is(err, exec.ErrNotFound) { + standalone, inspectErr := standaloneWithoutGit(directory) + if inspectErr != nil { + return "", false, inspectErr + } + if standalone { + return "", false, nil + } + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 128 && noRepositoryDiagnostic(stderr.String()) { + return "", false, nil + } + if message := strings.TrimSpace(stderr.String()); message != "" { + return "", false, fmt.Errorf("resolve Git repository root: %s: %w", message, err) + } + return "", false, fmt.Errorf("resolve Git repository root: %w", err) +} + +func noRepositoryDiagnostic(message string) bool { + message = strings.TrimSpace(message) + if message == "fatal: not a git repository (or any of the parent directories): .git" { + return true + } + const prefix = "fatal: not a git repository (or any parent up to mount point " + const suffix = ")\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set)." + return strings.HasPrefix(message, prefix) && strings.HasSuffix(message, suffix) && len(message) > len(prefix)+len(suffix) +} + +func standaloneWithoutGit(directory string) (bool, error) { + _, gitDirSet := os.LookupEnv("GIT_DIR") + _, workTreeSet := os.LookupEnv("GIT_WORK_TREE") + if gitDirSet || workTreeSet { + return false, nil + } + abs, err := filepath.Abs(directory) + if err != nil { + return false, fmt.Errorf("inspect repository directory: %w", err) + } + physical, err := filepath.EvalSymlinks(abs) + if err != nil { + return false, fmt.Errorf("inspect repository directory: %w", err) + } + info, err := os.Stat(physical) + if err != nil { + return false, fmt.Errorf("inspect repository directory: %w", err) + } + if !info.IsDir() { + return false, fmt.Errorf("inspect repository directory %q: not a directory", directory) + } + for dir := physical; ; dir = filepath.Dir(dir) { + _, err := os.Lstat(filepath.Join(dir, ".git")) + if err == nil { + return false, nil + } + if !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("inspect Git marker in %s: %w", dir, err) + } + if filepath.Dir(dir) == dir { + return true, nil + } + } +} + // CaptureHead records the checkout commit before identity derived from files // in that commit is inspected. func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {