Repository: git-town/git-town

Issue:
git.ParseGitStatusZ doesn't work with diff.renames=copies enabled
### Steps to reproduce

- run `git config --global diff.renames copies` to enable rename and copy detection
- have a branch with some open changes that copy a file
- run a Git Town command like `git town continue`


### Command output

```shell
$ git town continue

Error: cannot determine conflicts: invalid "git status -z" output: "vendor/golang.org/x/exp/PATENTS"
```

### Additional information

Running `git status` with the setting enabled results in this output:

```
$ git status | grep PATENTS
        copied:     vendor/golang.org/x/exp/PATENTS -> vendor/golang.org/x/mod/PATENTS
        copied:     vendor/golang.org/x/exp/PATENTS -> vendor/golang.org/x/net/PATENTS
        renamed:    vendor/golang.org/x/exp/PATENTS -> vendor/golang.org/x/tools/PATENTS

$ git town continue
Error: cannot determine conflicts: invalid "git status -z" output: "vendor/golang.org/x/exp/PATENTS"
```

If I disable the `diff.renames` entry in my Git config, I get this output and it works:

```
$ git status | grep PATENTS
        renamed:    vendor/golang.org/x/exp/PATENTS -> vendor/golang.org/x/mod/PATENTS
        new file:   vendor/golang.org/x/net/PATENTS
        new file:   vendor/golang.org/x/tools/PATENTS

$ git town continue
nothing to continue
```

A possible explanation is that `copied` entries show up without any prefix like `"DD", "AU", "UD", "UA", "DU", "AA", "UU"`.

Hints from issue comments:
@kevgo: Hey @stephenwade, looks like you originally implemented this logic in [file_status.go](https://github.com/git-town/git-town/blob/main/internal/git/file_status.go). Would you be up for taking a look? No pressure at all, happy to jump in myself if you’re busy.@stephenwade: Sure, I'm happy to take a look.@stephenwade: The issue is that copies (if detected), just like renames, have two filename fields just like renames. The current logic only expects two filename fields for renames, so for copies it processes the first filename and chokes on the second one.@kevgo: This is now available as part of [Git Town 20.2](https://github.com/git-town/git-town/releases/tag/v20.2.0).

Solution patch:
diff --git a/internal/git/file_status.go b/internal/git/file_status.go
index 3df3cc7b7..2a9e9fd7b 100644
--- a/internal/git/file_status.go
+++ b/internal/git/file_status.go
@@ -11,9 +11,9 @@ import (
 
 // information about each file reported by "git status -z"
 type FileStatus struct {
-	Path        string         // the path to the file
-	RenamedFrom Option[string] // if the file was renamed, the old path of the file
-	ShortStatus string         // a two-letter status code, explained at https://git-scm.com/docs/git-status#_short_format
+	OriginalPath Option[string] // if the file was renamed or copied, the old path of the file
+	Path         string         // the path to the file
+	ShortStatus  string         // a two-letter status code, explained at https://git-scm.com/docs/git-status#_short_format
 }
 
 func FileStatusIsUnmerged(status FileStatus) bool {
@@ -41,22 +41,22 @@ func ParseGitStatusZ(output string) ([]FileStatus, error) {
 			return nil, fmt.Errorf(messages.InvalidStatusOutput, entry)
 		}
 		path := entry[3:]
-		renamedFrom := None[string]()
-		if strings.Contains(status, "R") {
+		originalPath := None[string]()
+		if strings.Contains(status, "R") || strings.Contains(status, "C") {
 			i++
 			if i >= len(entries) {
 				return nil, fmt.Errorf(messages.InvalidStatusOutput, entry)
 			}
-			renamedFromString := entries[i]
-			if renamedFromString == "" {
+			originalPathString := entries[i]
+			if originalPathString == "" {
 				return nil, fmt.Errorf(messages.InvalidStatusOutput, entry)
 			}
-			renamedFrom = Some(renamedFromString)
+			originalPath = Some(originalPathString)
 		}
 		result = append(result, FileStatus{
-			Path:        path,
-			RenamedFrom: renamedFrom,
-			ShortStatus: status,
+			OriginalPath: originalPath,
+			Path:         path,
+			ShortStatus:  status,
 		})
 	}
 	return result, nil



Test patch:
diff --git a/internal/git/file_status_test.go b/internal/git/file_status_test.go
index 2910c3d5d..1724991d4 100644
--- a/internal/git/file_status_test.go
+++ b/internal/git/file_status_test.go
@@ -11,6 +11,21 @@ import (
 func TestParseGitStatusZ(t *testing.T) {
 	t.Parallel()
 
+	t.Run("copied file", func(t *testing.T) {
+		t.Parallel()
+		give := "C  vendor/golang.org/x/mod/PATENTS\x00vendor/golang.org/x/exp/PATENTS\x00"
+		have, err := git.ParseGitStatusZ(give)
+		must.NoError(t, err)
+		want := []git.FileStatus{
+			{
+				OriginalPath: Some("vendor/golang.org/x/exp/PATENTS"),
+				Path:         "vendor/golang.org/x/mod/PATENTS",
+				ShortStatus:  "C ",
+			},
+		}
+		must.Eq(t, want, have)
+	})
+
 	t.Run("one file", func(t *testing.T) {
 		t.Parallel()
 		give := " M internal/git/parse_git_status_z.go\x00"
@@ -18,9 +33,9 @@ func TestParseGitStatusZ(t *testing.T) {
 		must.NoError(t, err)
 		want := []git.FileStatus{
 			{
-				ShortStatus: " M",
-				Path:        "internal/git/parse_git_status_z.go",
-				RenamedFrom: None[string](),
+				OriginalPath: None[string](),
+				Path:         "internal/git/parse_git_status_z.go",
+				ShortStatus:  " M",
 			},
 		}
 		must.Eq(t, want, have)
@@ -33,9 +48,9 @@ func TestParseGitStatusZ(t *testing.T) {
 		must.NoError(t, err)
 		want := []git.FileStatus{
 			{
-				ShortStatus: "R ",
-				Path:        "internal/git/parse_git_status_z.go",
-				RenamedFrom: Some("internal/git/parse_git_status.go"),
+				OriginalPath: Some("internal/git/parse_git_status.go"),
+				Path:         "internal/git/parse_git_status_z.go",
+				ShortStatus:  "R ",
 			},
 		}
 		must.Eq(t, want, have)
@@ -48,14 +63,14 @@ func TestParseGitStatusZ(t *testing.T) {
 		must.NoError(t, err)
 		want := []git.FileStatus{
 			{
-				ShortStatus: " M",
-				Path:        "internal/git/parse_git_status_z.go",
-				RenamedFrom: None[string](),
+				OriginalPath: None[string](),
+				Path:         "internal/git/parse_git_status_z.go",
+				ShortStatus:  " M",
 			},
 			{
-				ShortStatus: " M",
-				Path:        "internal/git/parse_git_status_z_test.go",
-				RenamedFrom: None[string](),
+				OriginalPath: None[string](),
+				Path:         "internal/git/parse_git_status_z_test.go",
+				ShortStatus:  " M",
 			},
 		}
 		must.Eq(t, want, have)