Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,4 +429,56 @@ curl --request PUT \
# Equivalent CLI command
# Evaluates the previously exported env vars GITOPS_REPOSITORY_SERVER and GITOPS_REPOSITORY_SERVER_API_KEY
gitops patch applications/dev/service-foo/values.yaml .service.image.tag v42.0.1
```
```

#### Patching multiple files atomically
To patch more than one file, use `PUT <protocol>://<host>:<port>/api/v1/patches`.
All patches of the request are applied and committed as a **single commit**, which is useful for
matrix builds that update several services of the same release at once.

```bash
curl --request PUT \
--url $GITOPS_REPOSITORY_SERVER/patches \
--header 'content-type: application/json' \
--header "X-API-Key: $GITOPS_REPOSITORY_SERVER_API_KEY" \
--data '{
"actor": "ci-bot",
"files": [
{
"filePath": "applications/dev/service-foo/values.yaml",
"patches": [
{
"selector": ".service.image.tag",
"value": "v42.0.1"
}
]
},
{
"filePath": "applications/dev/service-bar/values.yaml",
"patches": [
{
"selector": ".service.image.tag",
"value": "v42.0.1"
}
]
}
]
}'
```

The response contains the id of the created commit:

```json
{ "message": "ok", "commit": "8f1c0a2e2a3b4c5d6e7f8091a2b3c4d5e6f70819" }
```

If none of the patches changed anything, the request still succeeds and `commit` is empty.
Requests are rejected with `400` and a descriptive error message if `files` is empty, if a file has
no patches, if a selector is empty, if a file path is listed twice, or if a file path is absolute or
points outside of the repository.
If a single file of the request cannot be patched (e.g. the file does not exist or a selector does
not match), the whole request fails with `500` and **no** file is changed.

The `actor` is optional and added to the commit message as a `Triggered by:` footer.

The single file endpoint `PUT /api/v1/patch` remains available and unchanged.
105 changes: 105 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import (
"testing"

"github.com/google/uuid"
"github.com/ldez/go-git-cmd-wrapper/v2/add"
"github.com/ldez/go-git-cmd-wrapper/v2/git"
"github.com/ldez/go-git-cmd-wrapper/v2/types"
"github.com/mxcd/gitops-cli/internal/util"
"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -193,3 +196,105 @@ func TestGitPullRebase(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "test B", string(data))
}

func TestGitRestore(t *testing.T) {

tempConnection := cloneTempRepository(t)
assert.NotNil(t, tempConnection)

relativeFilePath := path.Join("applications", "dev", "service-test", "values.yaml")
absoluteFilePath := path.Join(tempConnection.Options.Directory, relativeFilePath)

originalContents, err := os.ReadFile(absoluteFilePath)
assert.NoError(t, err)
assert.NotEmpty(t, originalContents)

err = os.WriteFile(absoluteFilePath, []byte("clobbered: true\n"), 0644)
assert.NoError(t, err)

hasChanges, err := tempConnection.HasChanges()
assert.NoError(t, err)
assert.True(t, hasChanges)

err = tempConnection.Restore([]string{relativeFilePath})
assert.NoError(t, err)

hasChanges, err = tempConnection.HasChanges()
assert.NoError(t, err)
assert.False(t, hasChanges)

restoredContents, err := os.ReadFile(absoluteFilePath)
assert.NoError(t, err)
assert.Equal(t, string(originalContents), string(restoredContents))
}

func TestGitRestoreStagedChanges(t *testing.T) {

tempConnection := cloneTempRepository(t)
assert.NotNil(t, tempConnection)

relativeFilePath := path.Join("applications", "dev", "service-test", "values.yaml")
absoluteFilePath := path.Join(tempConnection.Options.Directory, relativeFilePath)

originalContents, err := os.ReadFile(absoluteFilePath)
assert.NoError(t, err)

// simulate a commit that failed after `git add` already staged the change
err = os.WriteFile(absoluteFilePath, []byte("clobbered: true\n"), 0644)
assert.NoError(t, err)
_, err = git.Add(runGitIn(tempConnection.Options.Directory), add.PathSpec(relativeFilePath))
assert.NoError(t, err)
assert.True(t, hasStagedChanges(t, tempConnection))

err = tempConnection.Restore([]string{relativeFilePath})
assert.NoError(t, err)

assert.False(t, hasStagedChanges(t, tempConnection))
hasChanges, err := tempConnection.HasChanges()
assert.NoError(t, err)
assert.False(t, hasChanges)

restoredContents, err := os.ReadFile(absoluteFilePath)
assert.NoError(t, err)
assert.Equal(t, string(originalContents), string(restoredContents))
}

// hasStagedChanges reports whether the index differs from HEAD.
func hasStagedChanges(t *testing.T, connection *Connection) bool {
_, err := git.Raw("diff", runGitIn(connection.Options.Directory), func(g *types.Cmd) {
g.AddOptions("--cached")
g.AddOptions("--quiet")
})
return err != nil
}

func TestGitCommitFiles(t *testing.T) {

tempConnection := cloneTempRepository(t)
assert.NotNil(t, tempConnection)

headBefore, err := tempConnection.RevParse("HEAD")
assert.NoError(t, err)
assert.NotEmpty(t, headBefore)

testFileNameA := "test-file-" + uuid.New().String()
testFileNameB := "test-file-" + uuid.New().String()

for _, testFileName := range []string{testFileNameA, testFileNameB} {
err = os.WriteFile(path.Join(tempConnection.Options.Directory, testFileName), []byte("test"), 0644)
assert.NoError(t, err)
}

hash, err := tempConnection.Commit([]string{testFileNameA, testFileNameB}, "Test commit with two files")
assert.NoError(t, err)
assert.NotEmpty(t, hash)

files, err := tempConnection.CommitFiles(hash)
assert.NoError(t, err)
assert.ElementsMatch(t, []string{testFileNameA, testFileNameB}, files)

// the commit must sit directly on top of the previously resolved HEAD
parent, err := tempConnection.RevParse(hash + "^")
assert.NoError(t, err)
assert.Equal(t, headBefore, parent)
}
56 changes: 56 additions & 0 deletions internal/git/inspect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package git

import (
"fmt"
"strings"

"github.com/ldez/go-git-cmd-wrapper/v2/git"
"github.com/ldez/go-git-cmd-wrapper/v2/revparse"
"github.com/ldez/go-git-cmd-wrapper/v2/types"
"github.com/rs/zerolog/log"
)

// RevParse resolves the given revision to its commit id.
func (c *Connection) RevParse(revision string) (string, error) {
directory := c.Options.Directory
if directory == "" {
return "", fmt.Errorf("directory is not specified")
}

commitId, err := git.RevParse(runGitIn(directory), revparse.Args(revision))
if err != nil {
log.Error().Err(err).Str("output", commitId).Msgf("Failed to resolve revision %s", revision)
return "", err
}

return strings.TrimSpace(commitId), nil
}

// CommitFiles returns the repository relative paths of all files touched by the
// given commit.
func (c *Connection) CommitFiles(commitId string) ([]string, error) {
directory := c.Options.Directory
if directory == "" {
return nil, fmt.Errorf("directory is not specified")
}

msg, err := git.Raw("show", runGitIn(directory), func(g *types.Cmd) {
g.AddOptions("--name-only")
g.AddOptions("--format=")
g.AddOptions(commitId)
})
if err != nil {
log.Error().Err(err).Str("output", msg).Msgf("Failed to list files of commit %s", commitId)
return nil, err
}

files := []string{}
for _, line := range strings.Split(msg, "\n") {
line = strings.TrimSpace(line)
if line != "" {
files = append(files, line)
}
}

return files, nil
}
41 changes: 41 additions & 0 deletions internal/git/restore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package git

import (
"fmt"

"github.com/ldez/go-git-cmd-wrapper/v2/git"
"github.com/ldez/go-git-cmd-wrapper/v2/types"
"github.com/rs/zerolog/log"
)

// Restore discards working tree and index changes of the given repository
// relative file paths by running `git checkout HEAD -- <files>`. Restoring from
// HEAD instead of the index also discards changes that were already staged by a
// failed commit. It intentionally does not touch commits, so a local commit
// whose push failed is preserved and pushed by a subsequent pull/push cycle.
func (c *Connection) Restore(files []string) error {
directory := c.Options.Directory
if directory == "" {
return fmt.Errorf("directory is not specified")
}

if len(files) == 0 {
return nil
}

msg, err := git.Raw("checkout", runGitIn(directory), func(g *types.Cmd) {
g.AddOptions("HEAD")
g.AddOptions("--")
for _, file := range files {
g.AddOptions(file)
}
})
if err != nil {
log.Error().Err(err).Str("output", msg).Msg("Failed to restore files")
return err
}

log.Debug().Msgf("Restored %d file(s) in working tree", len(files))

return nil
}
Loading
Loading