From f19ca62e2524da8f39cfe0b60fc8fb14ba1ea7b4 Mon Sep 17 00:00:00 2001 From: purpleclay Date: Mon, 24 Jun 2024 06:22:30 +0100 Subject: [PATCH] initial changes to support reverting files --- gittest/repository.go | 11 ++++++ gittest/repository_test.go | 9 +++++ restore.go | 51 +++++++++++++++++++++++++++ restore_test.go | 51 +++++++++++++++++++++++++++ status.go | 63 ++++++++++++++++++++++++++++++++-- status_test.go | 29 +++++++++++++++- Taskfile.yaml => taskfile.yaml | 0 7 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 restore.go create mode 100644 restore_test.go rename Taskfile.yaml => taskfile.yaml (100%) diff --git a/gittest/repository.go b/gittest/repository.go index 557b6f4..c7984ca 100644 --- a/gittest/repository.go +++ b/gittest/repository.go @@ -653,6 +653,17 @@ func StagedFile(t *testing.T, path, content string) { StageFile(t, path) } +// Move or rename a file within the current repository (working directory). The +// following git command is executed: +// +// git mv --force '' '' +func Move(t *testing.T, path, to string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(to), 0o750)) + + MustExec(t, fmt.Sprintf("git mv --force '%s' '%s'", path, to)) +} + // Commit a snapshot of all changes within the current repository (working directory) // without pushing it to the remote. The commit will be associated with the // provided message. The following git command is executed: diff --git a/gittest/repository_test.go b/gittest/repository_test.go index adaf42d..154649f 100644 --- a/gittest/repository_test.go +++ b/gittest/repository_test.go @@ -373,6 +373,15 @@ func TestStageAll(t *testing.T) { assert.Contains(t, status, "A test2.txt") } +func TestMove(t *testing.T) { + gittest.InitRepository(t, gittest.WithCommittedFiles("file1.txt")) + + gittest.Move(t, "file1.txt", "file2.txt") + + status := gitExec(t, "status", "--porcelain") + assert.Contains(t, status, "R file1.txt -> file2.txt") +} + func TestCommit(t *testing.T) { gittest.InitRepository(t, gittest.WithStagedFiles("file.txt")) diff --git a/restore.go b/restore.go new file mode 100644 index 0000000..8292b92 --- /dev/null +++ b/restore.go @@ -0,0 +1,51 @@ +package git + +import ( + "fmt" + "strings" +) + +// RestoreUsing ...(against HEAD only) +func (c *Client) RestoreUsing(statuses []FileStatus) error { + for _, status := range statuses { + var err error + + if status.Untracked() { + err = c.removeUntrackedFile(status.Path) + } else if status.Modified() { + err = c.restoreFile(status) + } else if status.Renamed() { + err = c.undoRenamedFile(status.Path) + } + + if err != nil { + return err + } + } + + return nil +} + +func (c *Client) removeUntrackedFile(pathspec string) error { + _, err := c.exec("git clean --force -- " + pathspec) + return err +} + +func (c *Client) restoreFile(status FileStatus) error { + var buf strings.Builder + buf.WriteString("git restore ") + if status.Indicators[0] == Modified { + buf.WriteString("--staged ") + } + buf.WriteString(status.Path) + + _, err := c.exec(buf.String()) + return err +} + +func (c *Client) undoRenamedFile(pathspec string) error { + original, renamed, _ := strings.Cut(pathspec, porcelainRenameSeparator) + + _, err := c.exec(fmt.Sprintf("git mv %s %s", renamed, original)) + return err +} diff --git a/restore_test.go b/restore_test.go new file mode 100644 index 0000000..926dd2d --- /dev/null +++ b/restore_test.go @@ -0,0 +1,51 @@ +package git_test + +import ( + "testing" + + git "github.com/purpleclay/gitz" + "github.com/purpleclay/gitz/gittest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRestoreUsingForUntrackedFiles(t *testing.T) { + gittest.InitRepository(t, gittest.WithFiles("README.md", ".github/ci.yaml", "go.mod")) + + untracked := [2]git.FileStatusIndicator{git.Untracked, git.Untracked} + + client, _ := git.NewClient() + err := client.RestoreUsing([]git.FileStatus{ + {Indicators: untracked, Path: "README.md"}, + {Indicators: untracked, Path: ".github/"}, + {Indicators: untracked, Path: "go.mod"}, + }) + require.NoError(t, err) + + statuses := gittest.PorcelainStatus(t) + assert.Empty(t, statuses) +} + +func TestRestoreUsingForModifiedFiles(t *testing.T) { + // TODO: committed files + // TODO: modify one and stage + // TODO: modify the other and do not stage +} + +func TestRestoreUsingForRenamedFiles(t *testing.T) { + gittest.InitRepository(t, gittest.WithCommittedFiles("main.go", "cache.go", "keys.go")) + gittest.Move(t, "cache.go", "internal/cache/cache.go") + gittest.Move(t, "keys.go", "internal/cache/keys.go") + + renamed := [2]git.FileStatusIndicator{git.Renamed, git.Unmodified} + + client, _ := git.NewClient() + err := client.RestoreUsing([]git.FileStatus{ + {Indicators: renamed, Path: "cache.go -> internal/cache/cache.go"}, + {Indicators: renamed, Path: "keys.go -> internal/cache/keys.go"}, + }) + require.NoError(t, err) + + statuses := gittest.PorcelainStatus(t) + assert.Empty(t, statuses) +} diff --git a/status.go b/status.go index 681f2bd..5cc5a72 100644 --- a/status.go +++ b/status.go @@ -24,6 +24,8 @@ const ( Untracked FileStatusIndicator = '?' ) +const porcelainRenameSeparator = " -> " + // FileStatus represents the status of a file within a repository type FileStatus struct { // Indicators is a two character array that contains @@ -48,11 +50,68 @@ func (f FileStatus) String() string { return fmt.Sprintf("%c%c %s", f.Indicators[0], f.Indicators[1], f.Path) } +// Untracked identifies whether a file is not currently tracked +func (f FileStatus) Untracked() bool { + return f.Indicators[0] == Untracked && f.Indicators[1] == Untracked +} + +// Modified idenfities whether a file has been modified and therefore contains changes +func (f FileStatus) Modified() bool { + return f.Indicators[0] == Modified || f.Indicators[1] == Modified +} + +// Renamed identifies whether a file has been renamed +func (f FileStatus) Renamed() bool { + return f.Indicators[0] == Renamed +} + +// StatusOption provides a way for setting specific options during a +// porcelain status operation. Each support option can customize the list +// of file statuses identified within the current repository (working directory) +type StatusOption func(*statusOptions) + +type statusOptions struct { + IgnoreRenames bool + IgnoreUntracked bool +} + +// WithIgnoreRenames will turn off rename detection, removing any renamed +// files or directories from the retrieved file statuses +func WithIgnoreRenames() StatusOption { + return func(opts *statusOptions) { + opts.IgnoreRenames = true + } +} + +// WithIgnoreUntracked will remove any untracked files from the retrieved +// file statuses +func WithIgnoreUntracked() StatusOption { + return func(opts *statusOptions) { + opts.IgnoreUntracked = true + } +} + // PorcelainStatus identifies if there are any changes within the current // repository (working directory) and returns them in the parseable // porcelain v1 format -func (c *Client) PorcelainStatus() ([]FileStatus, error) { - log, err := c.exec("git status --porcelain") +func (c *Client) PorcelainStatus(opts ...StatusOption) ([]FileStatus, error) { + options := &statusOptions{} + for _, opt := range opts { + opt(options) + } + + var buf strings.Builder + buf.WriteString("git status --porcelain") + + if options.IgnoreRenames { + buf.WriteString(" --no-renames") + } + + if options.IgnoreUntracked { + buf.WriteString(" --untracked-files=no") + } + + log, err := c.exec(buf.String()) if err != nil { return nil, err } diff --git a/status_test.go b/status_test.go index ce69488..2b060fc 100644 --- a/status_test.go +++ b/status_test.go @@ -19,7 +19,34 @@ func TestPorcelainStatus(t *testing.T) { require.Len(t, statuses, 2) assert.ElementsMatch(t, []string{"?? README.md", "A go.mod"}, - []string{statuses[0].String(), statuses[1].String()}) + []string{statuses[0].String(), statuses[1].String()}, + ) +} + +func TestPorcelainStatusWithIgnoreUntracked(t *testing.T) { + gittest.InitRepository(t, gittest.WithFiles("README.md"), gittest.WithStagedFiles("go.mod")) + + client, _ := git.NewClient() + statuses, err := client.PorcelainStatus(git.WithIgnoreUntracked()) + require.NoError(t, err) + + require.Len(t, statuses, 1) + assert.ElementsMatch(t, []string{"A go.mod"}, []string{statuses[0].String()}) +} + +func TestPorcelainStatusWithIgnoreRenames(t *testing.T) { + gittest.InitRepository(t, gittest.WithFiles("go.mod"), gittest.WithCommittedFiles("README.md")) + gittest.Move(t, "README.md", "CONTRIBUTING.md") + + client, _ := git.NewClient() + statuses, err := client.PorcelainStatus(git.WithIgnoreRenames()) + require.NoError(t, err) + + require.Len(t, statuses, 3) + assert.ElementsMatch(t, + []string{"?? go.mod", "A CONTRIBUTING.md", "D README.md"}, + []string{statuses[0].String()}, + ) } func TestClean(t *testing.T) { diff --git a/Taskfile.yaml b/taskfile.yaml similarity index 100% rename from Taskfile.yaml rename to taskfile.yaml