diff --git a/pkg/gitinterface/status.go b/pkg/gitinterface/status.go index 12ee8c5a..ecf7dfc6 100644 --- a/pkg/gitinterface/status.go +++ b/pkg/gitinterface/status.go @@ -71,6 +71,8 @@ func NewStatusCodeFromByte(s byte) (StatusCode, error) { return StatusCodeAdded, nil case 'D': return StatusCodeDeleted, nil + case 'R': + return StatusCodeRenamed, nil case 'C': return StatusCodeCopied, nil case 'U': @@ -114,22 +116,26 @@ func (r *Repository) Status() (map[string]FileStatus, error) { statuses := map[string]FileStatus{} - lines := strings.Split(output, string('\000')) - for _, line := range lines { - if len(line) == 0 { + // `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 := line[0] - yb := line[1] + 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(line[2:]) + filePath := strings.TrimSpace(token[2:]) xStatus, err := NewStatusCodeFromByte(xb) if err != nil { @@ -144,6 +150,16 @@ func (r *Repository) Status() (map[string]FileStatus, error) { 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 diff --git a/pkg/gitinterface/status_test.go b/pkg/gitinterface/status_test.go index d5e16a8b..50417c9a 100644 --- a/pkg/gitinterface/status_test.go +++ b/pkg/gitinterface/status_test.go @@ -138,4 +138,48 @@ func TestStatus(t *testing.T) { statuses, err = repo.Status() assert.Nil(t, err) assert.Empty(t, statuses) + + // Rename one of the existing files + renamedPath := "baz" + if _, err := repo.executor("mv", filename, renamedPath).executeString(); err != nil { + t.Fatal(err) + } + + statuses, err = repo.Status() + assert.Nil(t, err) + assert.Equal(t, map[string]FileStatus{ + renamedPath: {X: StatusCodeRenamed, Y: StatusCodeUnmodified}, + }, statuses) + + // Commit the rename + if _, err := repo.executor("commit", "-m", "Commit\n").executeString(); err != nil { + t.Fatal(err) + } + + statuses, err = repo.Status() + assert.Nil(t, err) + assert.Empty(t, statuses) + + // Test that rename is parsed correctly alongside other changes in the same + // status output. This validates that the NUL-separated source-path token + // for the rename is consumed so subsequent entries are not mis-parsed. + renamedPath2 := "quux" + newFile := "qux" + + if _, err := repo.executor("mv", renamedPath, renamedPath2).executeString(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newFile, []byte("qux"), 0o644); err != nil { //nolint:gosec + t.Fatal(err) + } + if _, err := repo.executor("add", newFile).executeString(); err != nil { + t.Fatal(err) + } + + statuses, err = repo.Status() + assert.Nil(t, err) + assert.Equal(t, map[string]FileStatus{ + renamedPath2: {X: StatusCodeRenamed, Y: StatusCodeUnmodified}, + newFile: {X: StatusCodeAdded, Y: StatusCodeUnmodified}, + }, statuses) }