Skip to content

Commit

Permalink
add --require-clean-git-worktree flag to run and status commands
Browse files Browse the repository at this point in the history
Add a new flag to "baur run" and "baur status" called
--require-clean-git-worktree.
If passed, the commands fail if the baur repository contains untracked or
modified tracked git files, this includes files that are in .gitignore.

"baur run", checks the state on start and before executing a task, to catch
cases were multiple tasks are run and of them modifies or creates new files in
the git repository.

This helps to ensure that tasks are executed in an clean reproducible
environment.
  • Loading branch information
fho committed Jan 17, 2024
1 parent 5bdf746 commit ff5088a
Show file tree
Hide file tree
Showing 13 changed files with 200 additions and 27 deletions.
30 changes: 30 additions & 0 deletions internal/command/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"github.com/simplesurance/baur/v3/internal/command/term"
"github.com/simplesurance/baur/v3/internal/format"
"github.com/simplesurance/baur/v3/internal/log"
"github.com/simplesurance/baur/v3/internal/prettyprint"
"github.com/simplesurance/baur/v3/internal/vcs"
"github.com/simplesurance/baur/v3/internal/vcs/git"
"github.com/simplesurance/baur/v3/pkg/baur"
"github.com/simplesurance/baur/v3/pkg/cfg"
"github.com/simplesurance/baur/v3/pkg/storage"
Expand Down Expand Up @@ -218,6 +220,11 @@ func exitOnErrf(err error, format string, v ...interface{}) {
exitFunc(1)
}

func fatal(msg ...interface{}) {
stderr.PrintErrln(msg...)
exitFunc(1)
}

func exitOnErr(err error, msg ...interface{}) {
if err == nil {
return
Expand Down Expand Up @@ -247,3 +254,26 @@ func subStr(input string, start int, length int) string {

return string(asRunes[start : start+length])
}

func mustUntrackedFilesNotExist(requireCleanGitWorktree bool, vcsState vcs.StateFetcher) {
if !requireCleanGitWorktree {
return
}

if vcsState.Name() != git.Name {
exitOnErr(
fmt.Errorf("--%s was specified but baur repository is not a git repository", flagNameRequireCleanGitWorktree),
)
}

untracked, err := vcsState.UntrackedFiles()
exitOnErr(err)
if len(untracked) != 0 {
fatal(untrackedFilesExistErrMsg(untracked))
}
}

func untrackedFilesExistErrMsg(untrackedFiles []string) string {
return fmt.Sprintf("%s was specified, expecting only tracked unmodified files but found the following untracked or modified files:\n%s",
term.Highlight("--"+flagNameRequireCleanGitWorktree), term.Highlight(prettyprint.TruncatedStrSlice(untrackedFiles, 10)))
}
43 changes: 34 additions & 9 deletions internal/command/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/simplesurance/baur/v3/internal/upload/filecopy"
"github.com/simplesurance/baur/v3/internal/upload/s3"
"github.com/simplesurance/baur/v3/internal/vcs"
"github.com/simplesurance/baur/v3/internal/vcs/git"
"github.com/simplesurance/baur/v3/pkg/baur"
"github.com/simplesurance/baur/v3/pkg/storage"
)
Expand All @@ -30,6 +31,8 @@ baur run *.build run all tasks named build of the all applications and upload
baur run --force run and upload all tasks of applications, independent of their status
`

const flagNameRequireCleanGitWorktree = "require-clean-git-worktree"

var runLongHelp = fmt.Sprintf(`
Execute tasks of applications.
Expand Down Expand Up @@ -80,12 +83,13 @@ type runCmd struct {
cobra.Command

// Cmdline parameters
skipUpload bool
force bool
inputStr []string
lookupInputStr string
taskRunnerGoRoutines uint
showOutput bool
skipUpload bool
force bool
inputStr []string
lookupInputStr string
taskRunnerGoRoutines uint
showOutput bool
requireCleanGitWorktree bool

// other fields
storage storage.Storer
Expand Down Expand Up @@ -127,8 +131,10 @@ func newRunCmd() *runCmd {
"specifies the max. number of tasks to run in parallel")
cmd.Flags().BoolVarP(&cmd.showOutput, "show-task-output", "o", false,
"show the output of tasks, if disabled the output is only shown "+
"when task execution failed",
"when task execution fails",
)
cmd.Flags().BoolVarP(&cmd.requireCleanGitWorktree, flagNameRequireCleanGitWorktree, "c", false,
"fail if the git repository contains modified or untracked files")

return &cmd
}
Expand All @@ -146,6 +152,10 @@ func (c *runCmd) run(_ *cobra.Command, args []string) {
repo := mustFindRepository()
c.repoRootPath = repo.Path

c.vcsState = mustGetRepoState(repo.Path)

mustUntrackedFilesNotExist(c.requireCleanGitWorktree, c.vcsState)

c.storage = mustNewCompatibleStorage(repo)
defer c.storage.Close()

Expand All @@ -159,15 +169,17 @@ func (c *runCmd) run(_ *cobra.Command, args []string) {
c.taskRunner.LogFn = stderr.Printf
}

if c.requireCleanGitWorktree {
c.taskRunner.GitUntrackedFilesFn = git.UntrackedFiles
}

c.dockerClient, err = docker.NewClient(log.StdLogger.Debugf)
exitOnErr(err)

s3Client, err := s3.NewClient(ctx, log.StdLogger)
exitOnErr(err)
c.uploader = baur.NewUploader(c.dockerClient, s3Client, filecopy.New(log.Debugf))

c.vcsState = mustGetRepoState(repo.Path)

if c.skipUpload {
stdout.Printf("--skip-upload was passed, outputs won't be uploaded and task runs not recorded\n\n")
}
Expand All @@ -183,6 +195,13 @@ func (c *runCmd) run(_ *cobra.Command, args []string) {
pendingTasks, err := c.filterPendingTasks(tasks)
exitOnErr(err)

if c.requireCleanGitWorktree && len(pendingTasks) == 1 {
// if we only execute 1 task, the initial worktree check is
// sufficient, no other tasks ran in between that could have
// modified the worktree
c.taskRunner.GitUntrackedFilesFn = nil
}

stdout.PrintSep()

if c.force {
Expand Down Expand Up @@ -300,6 +319,12 @@ func (c *runCmd) runTask(task *baur.Task) (*baur.RunResult, error) {
return nil, err
}

var eUntracked *baur.ErrUntrackedGitFilesExist
if errors.As(err, &eUntracked) {
stderr.Println(untrackedFilesExistErrMsg(eUntracked.UntrackedFiles))
return nil, err
}

stderr.Printf("%s: executing %q %s: %s\n",
term.Highlight(task),
term.Highlight(result.Command),
Expand Down
20 changes: 20 additions & 0 deletions internal/command/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/simplesurance/baur/v3/internal/testutils/fstest"
"github.com/simplesurance/baur/v3/internal/testutils/gittest"
"github.com/simplesurance/baur/v3/internal/testutils/repotest"
"github.com/simplesurance/baur/v3/pkg/cfg"
)
Expand Down Expand Up @@ -350,3 +352,21 @@ func TestEnvVarInput_Optional(t *testing.T) {
})
})
}

func TestRunFailsWhenGitWorktreeIsDirty(t *testing.T) {
initTest(t)

r := repotest.CreateBaurRepository(t, repotest.WithNewDB())
gittest.CreateRepository(t, r.Dir)
r.CreateSimpleApp(t)
fname := "untrackedFile"
fstest.WriteToFile(t, []byte("hello"), filepath.Join(r.Dir, fname))

_, stderrBuf := interceptCmdOutput(t)
runCmd := newRunCmd()
runCmd.SetArgs([]string{"--" + flagNameRequireCleanGitWorktree})
require.Panics(t, func() { require.NoError(t, runCmd.Execute()) })

require.Contains(t, stderrBuf.String(), fname)
require.Contains(t, stderrBuf.String(), "expecting only tracked unmodified files")
}
23 changes: 15 additions & 8 deletions internal/command/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,14 @@ func init() {
type statusCmd struct {
cobra.Command

csv bool
quiet bool
absPaths bool
inputStr []string
lookupInputStr string
buildStatus flag.TaskStatus
fields *flag.Fields
csv bool
quiet bool
absPaths bool
inputStr []string
lookupInputStr string
buildStatus flag.TaskStatus
fields *flag.Fields
requireCleanGitWorktree bool
}

func newStatusCmd() *statusCmd {
Expand Down Expand Up @@ -104,6 +105,9 @@ func newStatusCmd() *statusCmd {
cmd.Flags().StringVar(&cmd.lookupInputStr, "lookup-input-str", "",
"if a run can not be found, try to find a run with this value as input-string")

cmd.Flags().BoolVarP(&cmd.requireCleanGitWorktree, flagNameRequireCleanGitWorktree, "c", false,
"fail if the git repository contains modified or untracked files")

return &cmd
}

Expand Down Expand Up @@ -139,10 +143,13 @@ func (c *statusCmd) run(_ *cobra.Command, args []string) {
var storageClt storage.Storer

repo := mustFindRepository()
vcsState := mustGetRepoState(repo.Path)

mustUntrackedFilesNotExist(c.requireCleanGitWorktree, vcsState)

loader, err := baur.NewLoader(
repo.Cfg,
mustGetRepoState(repo.Path).CommitID,
vcsState.CommitID,
log.StdLogger,
)
exitOnErr(err)
Expand Down
20 changes: 20 additions & 0 deletions internal/command/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/stretchr/testify/require"

"github.com/simplesurance/baur/v3/internal/testutils/dbtest"
"github.com/simplesurance/baur/v3/internal/testutils/fstest"
"github.com/simplesurance/baur/v3/internal/testutils/gittest"
"github.com/simplesurance/baur/v3/internal/testutils/repotest"
)

Expand Down Expand Up @@ -226,3 +228,21 @@ func TestStatusCombininingFieldAndStatusParameters(t *testing.T) {

require.Contains(t, stdoutBuf.String(), app.Name)
}

func TestStatusFailsWhenGitWorktreeIsDirty(t *testing.T) {
initTest(t)

r := repotest.CreateBaurRepository(t, repotest.WithNewDB())
gittest.CreateRepository(t, r.Dir)
r.CreateSimpleApp(t)
fname := "untrackedFile"
fstest.WriteToFile(t, []byte("hello"), filepath.Join(r.Dir, fname))

_, stderrBuf := interceptCmdOutput(t)
statusCmd := newStatusCmd()
statusCmd.SetArgs([]string{"--" + flagNameRequireCleanGitWorktree})
require.Panics(t, func() { require.NoError(t, statusCmd.Execute()) })

require.Contains(t, stderrBuf.String(), fname)
require.Contains(t, stderrBuf.String(), "expecting only tracked unmodified files")
}
11 changes: 8 additions & 3 deletions internal/command/term/streams.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

const separator = "------------------------------------------------------------------------------"

var errorPrefix = color.New(color.FgRed).Sprint("ERROR:")
var ErrorPrefix = color.New(color.FgRed).Sprint("ERROR:")

// Stream is a concurrency-safe output for term.messages.
type Stream struct {
Expand Down Expand Up @@ -49,12 +49,12 @@ func (s *Stream) TaskPrintf(task *baur.Task, format string, a ...interface{}) {
// The method prints the error in the format: errorPrefix msg: err
func (s *Stream) ErrPrintln(err error, msg ...interface{}) {
if len(msg) == 0 {
s.Println(errorPrefix, err)
s.Println(ErrorPrefix, err)
return
}

wholeMsg := fmt.Sprint(msg...)
s.Printf("%s %s: %s\n", errorPrefix, wholeMsg, err)
s.Printf("%s %s: %s\n", ErrorPrefix, wholeMsg, err)
}

// ErrPrintf prints an error with an optional printf-style message.
Expand All @@ -63,6 +63,11 @@ func (s *Stream) ErrPrintf(err error, format string, a ...interface{}) {
s.ErrPrintln(err, fmt.Sprintf(format, a...))
}

// PrintErrln prints as message that is prefixed with "ERROR: "
func (s *Stream) PrintErrln(msg ...interface{}) {
s.Println(ErrorPrefix, fmt.Sprint(msg...))
}

// PrintSep prints a separator line
func (s *Stream) PrintSep() {
fmt.Fprintln(s.stream, separator)
Expand Down
12 changes: 12 additions & 0 deletions internal/prettyprint/prettyprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package prettyprint
import (
"encoding/json"
"fmt"
"strings"
)

// AsString returns in as indented JSON
Expand All @@ -14,3 +15,14 @@ func AsString(in interface{}) string {

return string(res)
}

// TruncatedStrSlice returns sl as string, joined by ", ".
// If sl has more then maxElems, only the first maxElems elements will be
// returned and additional truncation marker.
func TruncatedStrSlice(sl []string, maxElems int) string {
if len(sl) <= maxElems {
return strings.Join(sl, ", ")
}

return strings.Join(sl[:maxElems], ", ") + ", [...]"
}
4 changes: 2 additions & 2 deletions internal/vcs/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ func WorktreeIsDirty(dir string) (bool, error) {
return true, nil
}

// UntrackedFiles returns a list of untracked files in the repository found at dir.
// The returned paths are relative to dir.
// UntrackedFiles returns a list of untracked and modified files in the git repository.
// Files that exist and are in a .gitignore file are included.
func UntrackedFiles(dir string) ([]string, error) {
const untrackedFilePrefix = "?? "
const ignoredFilePrefix = "!! "
Expand Down
12 changes: 12 additions & 0 deletions internal/vcs/git/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"sync"
)

const Name = "git"

// Repository reads information from a Git repository.
type Repository struct {
path string
Expand Down Expand Up @@ -114,3 +116,13 @@ func (g *Repository) initUntrackedFiles() error {

return nil
}

// UntrackedFiles returns a list of untracked and modified files in the git repository.
// Files that exist and are in a .gitignore file are included.
func (g *Repository) UntrackedFiles() ([]string, error) {
return UntrackedFiles(g.path)
}

func (g *Repository) Name() string {
return Name
}
8 changes: 8 additions & 0 deletions internal/vcs/novcsstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ func (*NoVCsState) WorktreeIsDirty() (bool, error) {
func (NoVCsState) WithoutUntracked(_ ...string) ([]string, error) {
return nil, ErrVCSRepositoryNotExist
}

func (*NoVCsState) Name() string {
return "none"
}

func (*NoVCsState) UntrackedFiles() ([]string, error) {
return nil, ErrVCSRepositoryNotExist
}
2 changes: 2 additions & 0 deletions internal/vcs/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ type StateFetcher interface {
CommitID() (string, error)
WorktreeIsDirty() (bool, error)
WithoutUntracked(paths ...string) ([]string, error)
Name() string
UntrackedFiles() ([]string, error)
}

var state = struct {
Expand Down
Loading

0 comments on commit ff5088a

Please sign in to comment.