// 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, err := r.GetWorktree() if err != nil { return nil, fmt.Errorf("unable to check status of repository: %w", err) } 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 }