package git import ( "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "path" "path/filepath" "strconv" "strings" "go.maximumdirect.net/releasekit/internal/process" ) // TreeEntry records one committed path and its Git object metadata. type TreeEntry struct { Path string ObjectID string Mode string Type string Size int64 HasSize bool } type TaggedSource struct { Head string Status string TagObject string TagCommit string TagObjectType string } // RepositoryRoot resolves the containing checkout, including independent // worktrees and directories reached through filesystem aliases. func RepositoryRoot(ctx context.Context, directory string) (string, error) { if directory == "" { directory = "." } root, err := run(ctx, directory, nil, "rev-parse", "--show-toplevel") 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 { return "", fmt.Errorf("resolve Git repository directory: %w", err) } 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", "GIT_NO_REPLACE_OBJECTS": "1"}, 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 { if boundary, noRepository := noRepositoryBoundary(stderr.String()); noRepository { boundaries := discoveryCeilings() if boundary != "" { boundaries = append(boundaries, boundary) } standalone, inspectErr := standaloneDirectory(directory, boundaries) if inspectErr != nil { return "", false, errors.Join(inspectErr, err) } if standalone { 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 noRepositoryBoundary(message string) (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)." if strings.HasPrefix(message, prefix) && strings.HasSuffix(message, suffix) && len(message) > len(prefix)+len(suffix) { return filepath.Clean(message[len(prefix) : len(message)-len(suffix)]), true } return "", false } func discoveryCeilings() []string { var ceilings []string resolveLinks := true for _, entry := range filepath.SplitList(os.Getenv("GIT_CEILING_DIRECTORIES")) { // Git uses an empty entry to mark subsequent paths as already physical. if entry == "" { resolveLinks = false continue } if !filepath.IsAbs(entry) { continue } if resolveLinks { resolved, err := filepath.EvalSymlinks(entry) if err != nil { continue // Git ignores ceiling entries it cannot resolve. } entry = resolved } ceilings = append(ceilings, filepath.Clean(entry)) } return ceilings } func standaloneWithoutGit(directory string) (bool, error) { _, gitDirSet := os.LookupEnv("GIT_DIR") _, workTreeSet := os.LookupEnv("GIT_WORK_TREE") if gitDirSet || workTreeSet { return false, nil } return standaloneDirectory(directory, nil) } // standaloneDirectory checks only locations Git could have searched. Boundaries // are excluded parents; the supplied directory itself is always inspected. func standaloneDirectory(directory string, boundaries []string) (bool, error) { 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) } parent := filepath.Dir(dir) if parent == dir { return true, nil } for _, boundary := range boundaries { if parent == boundary { 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) { head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { return "", fmt.Errorf("read checkout HEAD: %w", err) } head = strings.TrimSpace(head) if !validObjectID(head) { return "", fmt.Errorf("Git returned an invalid checkout commit %q", head) } return head, nil } // WorktreeStatus observes repository-wide worktree and index changes. func WorktreeStatus(ctx context.Context, repositoryDir string) (string, error) { status, err := run(ctx, repositoryDir, nil, "status", "--porcelain=v1", "--untracked-files=all") if err != nil { return "", err } return strings.TrimSpace(status), nil } // InspectTaggedSource reads the local HEAD, worktree state, and selected tag. // Both lightweight historical tags and annotated tags are supported. func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) { if err := CheckTagRef(ctx, repositoryDir, tag); err != nil { return TaggedSource{}, err } var source TaggedSource head, err := CaptureHead(ctx, repositoryDir) if err != nil { return TaggedSource{}, err } source.Head = head status, err := WorktreeStatus(ctx, repositoryDir) if err != nil { return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err) } source.Status = strings.TrimSpace(status) ref := "refs/tags/" + tag object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) if err != nil { return TaggedSource{}, fmt.Errorf("read local tag %s: %w", tag, err) } source.TagObject = strings.TrimSpace(object) if !validObjectID(source.TagObject) { return TaggedSource{}, fmt.Errorf("Git returned an invalid tag object %q", source.TagObject) } peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}") if err != nil { return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", tag, err) } source.TagCommit = strings.TrimSpace(peeled) if !validObjectID(source.TagCommit) { return TaggedSource{}, fmt.Errorf("Git returned an invalid tagged commit %q", source.TagCommit) } tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) if err != nil { return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", tag, err) } source.TagObjectType = strings.TrimSpace(tagType) if source.TagObjectType != "tag" && source.TagObjectType != "commit" { return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", tag, source.TagObjectType) } return source, nil } // ReadCommitFile reads a repository-relative file from a specific commit. func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]byte, error) { if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." || strings.Contains(name, "\\") { return nil, fmt.Errorf("invalid committed file identity %q at %q", name, commit) } content, err := readLocalObjects(ctx, repositoryDir, nil, "show", commit+":"+name) if err != nil { return nil, fmt.Errorf("read committed file %q: %w", name, err) } return content, nil } // CommittedPaths resolves individual paths from captured objects. It requests // only metadata and link contents needed for traversal. func CommittedPaths(repositoryDir, commit string) PathResolver { metadata := make(map[string]TreeEntry) return PathResolver{ RepositoryDir: repositoryDir, Lookup: func(ctx context.Context, name string) (TreeEntry, bool, error) { if err := ctx.Err(); err != nil { return TreeEntry{}, false, err } if entry, seen := metadata[name]; seen { return entry, entry.ObjectID != "", nil } if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || name == ".." || strings.HasPrefix(name, "../") { return TreeEntry{}, false, fmt.Errorf("invalid committed path identity %q at %q", name, commit) } output, err := readLocalObjects(ctx, repositoryDir, nil, "--literal-pathspecs", "ls-tree", "-z", "--full-tree", commit, "--", name) if err != nil { return TreeEntry{}, false, fmt.Errorf("inspect committed path %q: %w", name, err) } entries, err := parseTreeEntries(output) if err != nil { return TreeEntry{}, false, err } for _, entry := range entries { if entry.Path == name { metadata[name] = entry return entry, true, nil } } metadata[name] = TreeEntry{} return TreeEntry{}, false, nil }, ReadLink: func(ctx context.Context, entry TreeEntry) (string, error) { contents, err := ReadBlob(ctx, repositoryDir, entry.ObjectID) return string(contents), err }, } } // ReadTree enumerates path, type, mode, and object metadata for a commit. // Blob sizes and contents are inspected separately. Paths are NUL-delimited // by Git so whitespace in a filename is preserved. func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) { if !validObjectID(commit) { return nil, fmt.Errorf("invalid commit object ID %q", commit) } output, err := readLocalObjects(ctx, repositoryDir, nil, "ls-tree", "-r", "-z", "--full-tree", commit) if err != nil { return nil, fmt.Errorf("enumerate committed tree %s: %w", commit, err) } entries, err := parseTreeEntries(output) if err != nil { return nil, fmt.Errorf("parse committed tree %s: %w", commit, err) } return entries, nil } // ReadBlob reads the bytes for a Git blob object ID without consulting the // worktree. func ReadBlob(ctx context.Context, repositoryDir, objectID string) ([]byte, error) { if !validObjectID(objectID) { return nil, fmt.Errorf("invalid blob object ID %q", objectID) } content, err := readLocalObjects(ctx, repositoryDir, nil, "cat-file", "blob", objectID) if err != nil { return nil, fmt.Errorf("read blob %s: %w", objectID, err) } return content, nil } func parseTreeEntries(output []byte) ([]TreeEntry, error) { if len(output) == 0 { return nil, nil } var entries []TreeEntry for _, record := range bytes.Split(output, []byte{0}) { if len(record) == 0 { continue } tab := bytes.IndexByte(record, '\t') if tab < 0 { return nil, fmt.Errorf("tree record has no path separator") } fields := strings.Fields(string(record[:tab])) if len(fields) != 3 || !validObjectID(fields[2]) { return nil, fmt.Errorf("invalid tree record metadata %q", record[:tab]) } entries = append(entries, TreeEntry{ Mode: fields[0], Type: fields[1], ObjectID: fields[2], Path: string(record[tab+1:]), }) } return entries, nil } // PopulateBlobSizes fills unknown sizes for the supplied blob entries using // local objects. Callers select the entries whose sizes their check needs. func PopulateBlobSizes(ctx context.Context, repositoryDir string, entries []TreeEntry) error { if err := ctx.Err(); err != nil { return err } var blobIndexes []int var input strings.Builder for index := range entries { if entries[index].Type != "blob" || entries[index].HasSize { continue } blobIndexes = append(blobIndexes, index) input.WriteString(entries[index].ObjectID) input.WriteByte('\n') } if len(blobIndexes) == 0 { return nil } output, err := readLocalObjects(ctx, repositoryDir, strings.NewReader(input.String()), "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)") if err != nil { return err } lines := strings.Split(strings.TrimSuffix(string(output), "\n"), "\n") if len(lines) != len(blobIndexes) { return fmt.Errorf("git returned %d blob sizes for %d blobs", len(lines), len(blobIndexes)) } for index, line := range lines { fields := strings.Fields(line) entry := &entries[blobIndexes[index]] if len(fields) == 2 && fields[0] == entry.ObjectID && fields[1] == "missing" { return fmt.Errorf("committed blob %s for %q is unavailable locally", entry.ObjectID, entry.Path) } if len(fields) != 3 || fields[0] != entry.ObjectID || fields[1] != "blob" { return fmt.Errorf("unexpected blob size record %q", line) } size, err := strconv.ParseInt(fields[2], 10, 64) if err != nil || size < 0 { return fmt.Errorf("invalid blob size in record %q", line) } entry.Size = size entry.HasSize = true } return nil } // readLocalObjects keeps source inspection separate from network preparation. func readLocalObjects(ctx context.Context, repositoryDir string, input io.Reader, args ...string) ([]byte, error) { var stdout, stderr bytes.Buffer err := process.Run(ctx, process.Invocation{ Args: append([]string{"git"}, args...), Dir: repositoryDir, // Disable transports as well for Git versions predating lazy-fetch control. Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": "", "GIT_NO_REPLACE_OBJECTS": "1"}, Stdin: input, Stdout: &stdout, Stderr: &stderr, }) if err != nil { if message := strings.TrimSpace(stderr.String()); message != "" { return nil, fmt.Errorf("read local Git objects: %s: %w", message, err) } return nil, err } return stdout.Bytes(), nil }