gittuf/pkg/gitinterface/status.go
Andrew Nesbitt 12ab1b37ac
pkg/gitinterface: remove os.Chdir; set cmd.Dir on the executor instead
LoadRepository did os.Chdir(repositoryPath) to run 'git rev-parse
--git-dir', and Status/RestoreWorktree/tree-restore did the same to run
worktree-relative commands. os.Chdir is process-global; concurrent
LoadRepository calls for different paths could each resolve the wrong
gitDirPath.

Adds executor.withDir(dir) which sets cmd.Dir. LoadRepository now runs
'git rev-parse --absolute-git-dir' with cmd.Dir set to the repository
path (and EvalSymlinks the result to match prior behaviour). The
worktree commands use withDir(worktree).

TestLoadRepositoryConcurrent runs 20 pairs of LoadRepository for two
distinct repos in parallel under -race and asserts each gets its own
gitDirPath.

Signed-off-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-06-29 17:13:31 +01:00

157 lines
3.9 KiB
Go

// Copyright The gittuf Authors
// SPDX-License-Identifier: Apache-2.0
package gitinterface
import (
"errors"
"fmt"
"strings"
)
// See https://git-scm.com/docs/git-status#_porcelain_format_version_1.
var (
ErrInvalidStatusCodeLength = errors.New("status code string must be of length 1")
ErrInvalidStatusCode = errors.New("status code string is unrecognized")
)
type StatusCode uint
const (
StatusCodeUnmodified StatusCode = iota + 1 // we use 0 as error code
StatusCodeModified
StatusCodeTypeChanged
StatusCodeAdded
StatusCodeDeleted
StatusCodeRenamed
StatusCodeCopied
StatusCodeUpdatedUnmerged
StatusCodeUntracked
StatusCodeIgnored
)
func (s StatusCode) String() string {
switch s {
case StatusCodeUnmodified:
return " " // is this actually a space or empty string?
case StatusCodeModified:
return "M"
case StatusCodeTypeChanged:
return "T"
case StatusCodeAdded:
return "A"
case StatusCodeDeleted:
return "D"
case StatusCodeRenamed:
return "R"
case StatusCodeCopied:
return "C"
case StatusCodeUpdatedUnmerged:
return "U"
case StatusCodeUntracked:
return "?"
case StatusCodeIgnored:
return "!"
default:
return "invalid-code"
}
}
func NewStatusCodeFromByte(s byte) (StatusCode, error) {
switch s {
case ' ':
return StatusCodeUnmodified, nil
case 'M':
return StatusCodeModified, nil
case 'T':
return StatusCodeTypeChanged, nil
case 'A':
return StatusCodeAdded, nil
case 'D':
return StatusCodeDeleted, nil
case 'R':
return StatusCodeRenamed, nil
case 'C':
return StatusCodeCopied, nil
case 'U':
return StatusCodeUpdatedUnmerged, nil
case '?':
return StatusCodeUntracked, nil
case '!':
return StatusCodeIgnored, nil
default:
return 0, ErrInvalidStatusCode
}
}
type FileStatus struct {
X StatusCode
Y StatusCode
}
func (f *FileStatus) Untracked() bool {
return f.X == StatusCodeUntracked || f.Y == StatusCodeUntracked
}
func (r *Repository) Status() (map[string]FileStatus, error) {
worktree := r.gitDirPath
if !r.IsBare() {
worktree = strings.TrimSuffix(worktree, ".git") // TODO: this doesn't support detached git dir
}
output, err := r.executor("status", "--porcelain=1", "-z", "--untracked-files=all", "--ignored").withDir(worktree).executeString()
if err != nil {
return nil, fmt.Errorf("unable to check status of repository: %w", err)
}
statuses := map[string]FileStatus{}
// `git status --porcelain=1 -z` emits NUL-separated tokens.
// For rename/copy records, the source path is emitted as an additional
// token after the main status token.
tokens := strings.Split(output, string('\000'))
for i := 0; i < len(tokens); i++ {
token := tokens[i]
if len(token) == 0 {
continue
}
// first two characters are status codes, find the corresponding
// statuses
xb := token[0]
yb := token[1]
// Note: we identify the status after inspecting the path so we can
// provide better error messages
// then, we have a single space followed by the path, ignore space and
// read in the rest as the filepath
filePath := strings.TrimSpace(token[2:])
xStatus, err := NewStatusCodeFromByte(xb)
if err != nil {
return nil, fmt.Errorf("unable to parse status code '%c' for path '%s': %w", xb, filePath, err)
}
yStatus, err := NewStatusCodeFromByte(yb)
if err != nil {
return nil, fmt.Errorf("unable to parse status code '%c' for path '%s': %w", yb, filePath, err)
}
status := FileStatus{X: xStatus, Y: yStatus}
statuses[filePath] = status
// After splitting on NUL, rename/copy records have an additional token
// for the source path immediately after the main token.
if xStatus == StatusCodeRenamed || xStatus == StatusCodeCopied ||
yStatus == StatusCodeRenamed || yStatus == StatusCodeCopied {
if i+1 >= len(tokens) || len(tokens[i+1]) == 0 {
return nil, fmt.Errorf("unable to parse rename/copy status for path '%s': missing source path", filePath)
}
i++
}
}
return statuses, nil
}