gittuf/pkg/gitinterface/status.go
ayuxsh009 bfda5df850 fix: parse rename status
Signed-off-by: ayuxsh009 <1raj.aayush@gmail.com>
2026-04-30 01:41:16 +05:30

166 lines
4 KiB
Go

// Copyright The gittuf Authors
// SPDX-License-Identifier: Apache-2.0
package gitinterface
import (
"errors"
"fmt"
"os"
"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
}
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
if err := os.Chdir(worktree); err != nil {
return nil, err
}
defer os.Chdir(cwd) //nolint:errcheck
output, err := r.executor("status", "--porcelain=1", "-z", "--untracked-files=all", "--ignored").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
}