Skip to content

Commit

Permalink
initial changes to support reverting files
Browse files Browse the repository at this point in the history
  • Loading branch information
purpleclay committed Jun 24, 2024
1 parent ca30fed commit f19ca62
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 3 deletions.
11 changes: 11 additions & 0 deletions gittest/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<path>' '<to>'
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:
Expand Down
9 changes: 9 additions & 0 deletions gittest/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
51 changes: 51 additions & 0 deletions restore.go
Original file line number Diff line number Diff line change
@@ -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
}
51 changes: 51 additions & 0 deletions restore_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
63 changes: 61 additions & 2 deletions status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
29 changes: 28 additions & 1 deletion status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
File renamed without changes.

0 comments on commit f19ca62

Please sign in to comment.