diff --git a/go.mod b/go.mod index 429f0957..b3071996 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( golang.org/x/oauth2 v0.30.0 golang.org/x/tools v0.34.0 gopkg.in/inf.v0 v0.9.1 + gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.24.3 k8s.io/code-generator v0.17.2 sigs.k8s.io/yaml v1.4.0 @@ -103,7 +104,6 @@ require ( golang.org/x/text v0.26.0 // indirect gonum.org/v1/gonum v0.0.0-20190331200053-3d26580ed485 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c // indirect k8s.io/klog v1.0.0 // indirect k8s.io/klog/v2 v2.70.1 // indirect diff --git a/pkg/vendir/cmd/baseline.go b/pkg/vendir/cmd/baseline.go new file mode 100644 index 00000000..8af6150d --- /dev/null +++ b/pkg/vendir/cmd/baseline.go @@ -0,0 +1,241 @@ +// Copyright 2024 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + "path" + + "github.com/cppforlife/go-cli-ui/ui" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + ctlconf "carvel.dev/vendir/pkg/vendir/config" + ctldir "carvel.dev/vendir/pkg/vendir/directory" + ctlcache "carvel.dev/vendir/pkg/vendir/fetch/cache" +) + +func NewBaselineOptions(ui ui.UI) *BaselineOptions { //nolint:revive + return &BaselineOptions{ui: ui} +} + +func NewBaselineCmd(o *BaselineOptions) *cobra.Command { + cmd := cobra.Command{ + Use: "baseline", + Short: "Update git/hg repositories 'ref' to match the current commits", + RunE: func(_ *cobra.Command, _ []string) error { return o.Run() }, + } + + cmd.Flags().BoolVarP( + &o.Yes, "yes", "y", false, + "If true, automatically answer 'yes' to all the questions") + + cmd.Flags().StringSliceVarP( + &o.Files, "file", "f", []string{defaultConfigName}, + "Set configuration file") + cmd.Flags().StringVar( + &o.LockFile, "lock-file", defaultLockName, "Set lock file") + cmd.Flags().StringVar( + &o.Chdir, "chdir", "", "Set current directory for process") + cmd.Flags().BoolVar( + &o.PreferSHA, "prefer-sha", false, "Prefer sha instead of tags") + cmd.Flags().BoolVar(&o.DryRun, "dry-run", false, "List what would be done") + + return &cmd +} + +type BaselineOptions struct { + ui ui.UI + + Files []string + LockFile string + + Chdir string + ExitCode bool + + PreferSHA bool + Yes bool + DryRun bool +} + +func (o *BaselineOptions) Run() error { + if len(o.Chdir) > 0 { //nolint:revive + err := os.Chdir(o.Chdir) + if err != nil { + return fmt.Errorf("Running chdir: %s", err) + } + } + + conf, secrets, configMaps, err := ctlconf.NewConfigFromFiles(o.Files) + if err != nil { + return (*SyncOptions)(nil).configReadHintErrMsg(err, o.Files) + } + + existingLockConfig, err := ctlconf.NewLockConfigFromFile(o.LockFile) + if err != nil { + return err + } + + cache, err := ctlcache.NewCache(os.Getenv("VENDIR_CACHE_DIR"), "0Mi") + if err != nil { + return fmt.Errorf("Unable to create cache: %s", err) + } + syncOpts := ctldir.SyncOpts{ + RefFetcher: ctldir.NewNamedRefFetcher(secrets, configMaps), + GithubAPIToken: os.Getenv("VENDIR_GITHUB_API_TOKEN"), + HelmBinary: os.Getenv("VENDIR_HELM_BINARY"), + Cache: cache, + Lazy: false, + Partial: false, + } + + statusMap, err := fullStatus(conf, syncOpts, existingLockConfig, o.ui) + if err != nil { + return err + } + + newRefs := make(map[string]string) + + for _, status := range statusMap { + if !status.MatchTarget() || + o.PreferSHA && !status.MatchTargetSHA() || + !o.PreferSHA && !status.MatchTargetTag() && len(status.Ref.Tags) != 0 { //nolint:revive + var newRef string + if o.PreferSHA || len(status.Ref.Tags) == 0 { //nolint:revive + newRef = status.Ref.SHA + } else { + newRef = status.Ref.Tags[0] //nolint:revive + } + newRefs[status.Path()] = newRef + } + } + + if len(newRefs) == 0 { //nolint:revive + o.ui.PrintLinef( + "All references already match current state, no update needed") + + return nil + } + + block := "New baseline:\n" + for _, status := range statusMap { + newRef := newRefs[status.Path()] + if newRef != "" { + newRef = " -> " + newRef + } + block += fmt.Sprintf( + "- %s/%s: %s%s\n", + status.DirectoryPath, status.ContentPath, status.TargetRef, newRef) + } + o.ui.PrintBlock([]byte(block)) + + if !o.DryRun { + if o.Yes || o.ui.AskForConfirmation() == nil { + for _, fname := range o.Files { + if err := updateRefs(fname, newRefs); err != nil { + return err + } + o.ui.PrintLinef("Updated '%s'", fname) + } + } + } + + return nil +} + +func loadFile(fname string) (*yaml.Node, error) { + f, err := os.Open(fname) + if err != nil { + return nil, err + } + + defer f.Close() + + var node yaml.Node + + if err := yaml.NewDecoder(f).Decode(&node); err != nil { + return nil, err + } + + return &node, err +} + +func saveFile(fname string, doc *yaml.Node) error { + f, err := os.Create(fname) + if err != nil { + return err + } + + enc := yaml.NewEncoder(f) + enc.SetIndent(2) //nolint:revive + if err := enc.Encode(doc); err != nil { + _ = f.Close() + + return err + } + + return f.Close() +} + +func updateRefs(fname string, newRefs map[string]string) error { + doc, err := loadFile(fname) + if err != nil { + return err + } + + if doc.Kind != yaml.DocumentNode { + panic("expects the root node") + } + + top := doc.Content[0] //nolint:revive + if top.Kind != yaml.MappingNode { + panic("top content must be a mapping") + } + + directories := getMappingNodeChild(top, "directories") + + for _, d := range directories.Content { + dirPath := getMappingNodeChild(d, "path").Value + + contents := getMappingNodeChild(d, "contents") + + for _, content := range contents.Content { + contentPath := getMappingNodeChild(content, "path").Value + + fullPath := path.Join(dirPath, contentPath) + + if newRef, ok := newRefs[fullPath]; ok { + if hg := getMappingNodeChild(content, "hg"); hg != nil { + ref := getMappingNodeChild(hg, "ref") + if ref == nil { + return fmt.Errorf( + "could not find 'ref' for '%s'", fullPath) + } + ref.Value = newRef + } + if git := getMappingNodeChild(content, "git"); git != nil { + ref := getMappingNodeChild(git, "ref") + if ref == nil { + return fmt.Errorf( + "could not find 'ref' for '%s'", fullPath) + } + ref.Value = newRef + } + } + } + } + + return saveFile(fname, doc) +} + +func getMappingNodeChild(node *yaml.Node, name string) *yaml.Node { + for i := 0; i < len(node.Content); i += 2 { //nolint:revive + if node.Content[i].Value == name { + return node.Content[i+1] //nolint:revive + } + } + + return nil +} diff --git a/pkg/vendir/cmd/status.go b/pkg/vendir/cmd/status.go new file mode 100644 index 00000000..a7af0b3a --- /dev/null +++ b/pkg/vendir/cmd/status.go @@ -0,0 +1,119 @@ +// Copyright 2025 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + + "github.com/cppforlife/go-cli-ui/ui" + "github.com/spf13/cobra" + + ctlconf "carvel.dev/vendir/pkg/vendir/config" + ctldir "carvel.dev/vendir/pkg/vendir/directory" + ctlcache "carvel.dev/vendir/pkg/vendir/fetch/cache" + ctlstatus "carvel.dev/vendir/pkg/vendir/status" +) + +type StatusOptions struct { + ui ui.UI + + Files []string + LockFile string + + Chdir string + ExitCode bool +} + +func NewStatusOptions(ui ui.UI) *StatusOptions { //nolint:revive + return &StatusOptions{ui: ui} +} + +func NewStatusCmd(o *StatusOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Check local repositories status", + RunE: func(_ *cobra.Command, _ []string) error { return o.Run() }, + } + + cmd.Flags().StringSliceVarP( + &o.Files, "file", "f", + []string{defaultConfigName}, "Set configuration file") + cmd.Flags().StringVar( + &o.LockFile, "lock-file", defaultLockName, "Set lock file") + cmd.Flags().StringVar( + &o.Chdir, "chdir", "", "Set current directory for process") + cmd.Flags().BoolVar( + &o.ExitCode, "exit-code", false, + "Set to 'true', it exits with a non-0 code if any "+ + "subproject is not clean") + + return cmd +} + +func (o *StatusOptions) Run() error { + if len(o.Chdir) > 0 { //nolint:revive + err := os.Chdir(o.Chdir) + if err != nil { + return fmt.Errorf("Running chdir: %s", err) + } + } + + conf, secrets, configMaps, err := ctlconf.NewConfigFromFiles(o.Files) + if err != nil { + return (*SyncOptions)(nil).configReadHintErrMsg(err, o.Files) + } + + existingLockConfig, err := ctlconf.NewLockConfigFromFile(o.LockFile) + if err != nil { + return err + } + + cache, err := ctlcache.NewCache(os.Getenv("VENDIR_CACHE_DIR"), "0Mi") + if err != nil { + return fmt.Errorf("Unable to create cache: %s", err) + } + syncOpts := ctldir.SyncOpts{ + RefFetcher: ctldir.NewNamedRefFetcher(secrets, configMaps), + GithubAPIToken: os.Getenv("VENDIR_GITHUB_API_TOKEN"), + HelmBinary: os.Getenv("VENDIR_HELM_BINARY"), + Cache: cache, + Lazy: false, + Partial: false, + } + + status, err := fullStatus(conf, syncOpts, existingLockConfig, o.ui) + if err != nil { + return err + } + + o.ui.PrintBlock([]byte("---------------\n\n")) + + o.ui.PrintTable(status.Table()) + + return nil +} + +func fullStatus( + conf ctlconf.Config, + syncOpts ctldir.SyncOpts, + existingLockConfig ctlconf.LockConfig, + ui ui.UI, //nolint:revive +) (ctlstatus.StatusList, error) { + status := ctlstatus.StatusList{} + for _, dirConf := range conf.Directories { + dirExistingLockConf, _ := existingLockConfig.FindDirectory(dirConf.Path) + directory := ctldir.NewDirectory(dirConf, dirExistingLockConf, ui) + + dirStatus, err := directory.Status(syncOpts) + if err != nil { + return nil, fmt.Errorf( + "Reading directory '%s': %s", dirConf.Path, err) + } + + status = append(status, dirStatus...) + } + + return status, nil +} diff --git a/pkg/vendir/cmd/sync.go b/pkg/vendir/cmd/sync.go index 4e2cc816..8dc4d1c1 100644 --- a/pkg/vendir/cmd/sync.go +++ b/pkg/vendir/cmd/sync.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" ctlconf "carvel.dev/vendir/pkg/vendir/config" @@ -22,6 +23,12 @@ const ( defaultLockName = "vendir.lock.yml" ) +var ( + defaultSafeFlagValue = slices.Contains([]string{ + "y", "yes", "Y", "YES", "1", "t", "True", "true", "TRUE", + }, os.Getenv("VENDIR_SYNC_SAFE")) +) + type SyncOptions struct { ui ui.UI @@ -34,6 +41,8 @@ type SyncOptions struct { Chdir string AllowAllSymlinkDestinations bool + + Safe bool } func NewSyncOptions(ui ui.UI) *SyncOptions { @@ -56,6 +65,9 @@ func NewSyncCmd(o *SyncOptions) *cobra.Command { cmd.Flags().StringVar(&o.Chdir, "chdir", "", "Set current directory for process") cmd.Flags().BoolVar(&o.AllowAllSymlinkDestinations, "dangerous-allow-all-symlink-destinations", false, "Symlinks to all destinations are allowed") + cmd.Flags().BoolVar( + &o.Safe, "safe", defaultSafeFlagValue, "sync only if local DVCS clones are clean") + return cmd } @@ -137,6 +149,18 @@ func (o *SyncOptions) Run() error { } newLockConfig := ctlconf.NewLockConfig() + if o.Safe { + status, err := fullStatus(conf, syncOpts, existingLockConfig, o.ui) + if err != nil { + return err + } + + if !status.IsSafe() { + o.ui.PrintTable(status.Table()) + return fmt.Errorf("--safe mode forbids a sync") + } + } + for _, dirConf := range conf.Directories { // error safe to ignore, since lock file might not exist dirExistingLockConf, _ := existingLockConfig.FindDirectory(dirConf.Path) diff --git a/pkg/vendir/cmd/vendir.go b/pkg/vendir/cmd/vendir.go index a16722e5..cccb98ac 100644 --- a/pkg/vendir/cmd/vendir.go +++ b/pkg/vendir/cmd/vendir.go @@ -42,7 +42,9 @@ func NewVendirCmd(o *VendirOptions) *cobra.Command { o.UIFlags.Set(cmd) cmd.AddCommand(NewSyncCmd(NewSyncOptions(o.ui))) + cmd.AddCommand(NewStatusCmd(NewStatusOptions(o.ui))) cmd.AddCommand(NewVersionCmd(NewVersionOptions(o.ui))) + cmd.AddCommand(NewBaselineCmd(NewBaselineOptions(o.ui))) toolsCmd := NewToolsCmd() toolsCmd.AddCommand(NewSortSemverCmd(NewSortSemverOptions(o.ui))) diff --git a/pkg/vendir/directory/status.go b/pkg/vendir/directory/status.go new file mode 100644 index 00000000..f2535ac0 --- /dev/null +++ b/pkg/vendir/directory/status.go @@ -0,0 +1,54 @@ +// Copyright 2025 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package directory + +import ( + "path" + + ctlgit "carvel.dev/vendir/pkg/vendir/fetch/git" + ctlhg "carvel.dev/vendir/pkg/vendir/fetch/hg" + ctlstatus "carvel.dev/vendir/pkg/vendir/status" +) + +func (d *Directory) Status(syncOpts SyncOpts) (ctlstatus.StatusList, error) { + var res ctlstatus.StatusList + + for _, contents := range d.opts.Contents { + contentPath := path.Join(d.opts.Path, contents.Path) + switch { + case contents.Git != nil: + gitSync := ctlgit.NewSync( + *contents.Git, NewInfoLog(d.ui), + syncOpts.RefFetcher, syncOpts.Cache) + + gitStatus, err := gitSync.Status(contentPath) + if err != nil { + return nil, err + } + + if gitStatus != nil { + gitStatus.DirectoryPath = d.opts.Path + gitStatus.ContentPath = contents.Path + res = append(res, gitStatus) + } + case contents.Hg != nil: + hgSync := ctlhg.NewSync( + *contents.Hg, NewInfoLog(d.ui), + syncOpts.RefFetcher, syncOpts.Cache) + + hgStatus, err := hgSync.Status(contentPath) + if err != nil { + return nil, err + } + + if hgStatus != nil { + hgStatus.DirectoryPath = d.opts.Path + hgStatus.ContentPath = contents.Path + res = append(res, hgStatus) + } + } + } + + return res, nil +} diff --git a/pkg/vendir/fetch/git/status.go b/pkg/vendir/fetch/git/status.go new file mode 100644 index 00000000..7999d9bd --- /dev/null +++ b/pkg/vendir/fetch/git/status.go @@ -0,0 +1,82 @@ +// Copyright 2025 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package git + +import ( + "os" + "path" + "strings" + + ctlstatus "carvel.dev/vendir/pkg/vendir/status" +) + +func (d Sync) Status(target string) (*ctlstatus.Status, error) { + _, err := os.Stat(path.Join(target, ".git")) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, err + } + + git := NewGit(d.opts, d.log, d.refFetcher) + + status := ctlstatus.Status{ + TargetRef: d.opts.Ref, + } + + out, _, err := git.cmdRunner.Run( + []string{"rev-parse", "HEAD"}, []string{}, target) + if err != nil { + return nil, err + } + status.Ref.SHA = strings.TrimSpace(out) + + out, _, err = git.cmdRunner.Run( + []string{"tag", "--contains"}, []string{}, target) + if err != nil { + return nil, err + } + if out != "" { + status.Ref.Tags = strings.Split(strings.TrimSpace(out), "\n") + } + + out, _, err = git.cmdRunner.Run( + []string{"branch", "--contains"}, []string{}, target) + if err != nil { + return nil, err + } + if out != "" { + status.Ref.Others = strings.Split(out, "\n") + } + + out, _, err = git.cmdRunner.Run( + []string{"log", "--branches", "--not", "--remotes", "--oneline"}, + []string{}, + target, + ) + if err != nil { + return nil, err + } + + if out != "" { + status.LocalCsets = strings.Split(strings.TrimSpace(out), "\n") + } + + out, _, err = git.cmdRunner.Run( + []string{"status", "--short"}, + []string{}, + target, + ) + if err != nil { + return nil, err + } + + if out != "" { + status.UncommitedChanges = strings.Split(strings.TrimSpace(out), "\n") + } + + return &status, nil +} diff --git a/pkg/vendir/fetch/hg/hg.go b/pkg/vendir/fetch/hg/hg.go index aa24ff40..665517b6 100644 --- a/pkg/vendir/fetch/hg/hg.go +++ b/pkg/vendir/fetch/hg/hg.go @@ -33,8 +33,10 @@ func NewHg(opts ctlconf.DirectoryContentsHg, tempArea ctlfetch.TempArea, ) (*Hg, error) { t := Hg{opts, infoLog, refFetcher, "", nil, ""} - if err := t.setup(tempArea); err != nil { - return nil, err + if tempArea != nil { + if err := t.setup(tempArea); err != nil { + return nil, err + } } return &t, nil } @@ -236,7 +238,7 @@ func (t *Hg) run(args []string, dstPath string) (string, string, error) { err := cmd.Run() if err != nil { - return "", "", fmt.Errorf("Hg %s: %s (stderr: %s)", args, err, stderrBs.String()) + return "", "", fmt.Errorf("Hg %s: %w (stderr: %s)", args, err, stderrBs.String()) } return stdoutBs.String(), stderrBs.String(), nil diff --git a/pkg/vendir/fetch/hg/status.go b/pkg/vendir/fetch/hg/status.go new file mode 100644 index 00000000..1a71857c --- /dev/null +++ b/pkg/vendir/fetch/hg/status.go @@ -0,0 +1,93 @@ +// Copyright 2025 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package hg + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path" + "slices" + "strings" + + ctlstatus "carvel.dev/vendir/pkg/vendir/status" +) + +const emptyString = "" + +func (d Sync) Status(target string) (*ctlstatus.Status, error) { + _, err := os.Stat(path.Join(target, ".hg")) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, err + } + + hg, err := NewHg(d.opts, d.log, d.refFetcher, nil) + if err != nil { + return nil, fmt.Errorf("Setting up hg: %w", err) + } + defer hg.Close() + + out, _, err := hg.run([]string{ + "log", "-r", ".", + "-T", "{node}\n{tags}\n{branch}\n{topic}\n{bookmarks}\n", + }, target) + if err != nil { + return nil, err + } + + splitted := strings.Split(out, "\n") + sha := splitted[0] //nolint:revive + tags := splitted[1] //nolint:revive + branch := splitted[2] //nolint:revive + topic := splitted[3] //nolint:revive + bookmarks := splitted[4] //nolint:revive + + status := ctlstatus.Status{ + TargetRef: d.opts.Ref, + Ref: ctlstatus.CompleteReference{ + SHA: sha, + }, + } + if tags != emptyString { + status.Ref.Tags = strings.Split(tags, " ") + status.Ref.Tags = slices.DeleteFunc( + status.Ref.Tags, func(t string) bool { return t == "tip" }) + } + if branch != emptyString { + status.Ref.Others = append(status.Ref.Others, branch) + } + if topic != emptyString { + status.Ref.Others = append(status.Ref.Others, topic) + } + if bookmarks != emptyString { + status.Ref.Others = append(status.Ref.Others, bookmarks) + } + + out, _, err = hg.run([]string{"status"}, target) + if err != nil { + return nil, err + } + if out != emptyString { + status.UncommitedChanges = strings.Split(strings.TrimSpace(out), "\n") + } + + out, _, err = hg.run([]string{"out", "-q", "-T", "{node}\n"}, target) + if err != nil { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 { + return nil, err + } + } + + if out != emptyString { + status.LocalCsets = strings.Split(strings.TrimSpace(out), "\n") + } + + return &status, nil +} diff --git a/pkg/vendir/status/status.go b/pkg/vendir/status/status.go new file mode 100644 index 00000000..08320bbc --- /dev/null +++ b/pkg/vendir/status/status.go @@ -0,0 +1,156 @@ +// Copyright 2025 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "fmt" + "path" + "slices" + "strings" + + "github.com/cppforlife/go-cli-ui/ui/table" +) + +type CompleteReference struct { + SHA string + Tags []string + Others []string +} + +type Status struct { + DirectoryPath string + ContentPath string + TargetRef string + Ref CompleteReference + UncommitedChanges []string + LocalCsets []string +} + +func (s Status) Path() string { + return path.Join(s.DirectoryPath, s.ContentPath) +} + +func (s Status) IsSafe() bool { + return len(s.UncommitedChanges) == 0 && len(s.LocalCsets) == 0 +} + +func (s Status) MatchTarget() bool { + return s.MatchTargetSHA() || + s.MatchTargetTag() || + slices.Contains(s.Ref.Others, s.TargetRef) +} + +func (s Status) MatchTargetTag() bool { + return slices.Contains(s.Ref.Tags, s.TargetRef) +} + +func (s Status) MatchTargetSHA() bool { + return strings.HasPrefix(s.Ref.SHA, s.TargetRef) +} + +func (s Status) String() string { + messages := make([]string, 0, 3) + + if s.IsSafe() { + messages = append(messages, "clean") + } + + if len(s.UncommitedChanges) != 0 { + messages = append(messages, fmt.Sprintf("%d uncommited changes", len(s.UncommitedChanges))) + } + if len(s.LocalCsets) != 0 { + messages = append(messages, fmt.Sprintf("%d unpushed commits", len(s.LocalCsets))) + } + + if !s.MatchTarget() { + messages = append(messages, "ref mismatch") + } + + return strings.Join(messages, ", ") +} + +func (s Status) toRow() []table.Value { + row := make([]table.Value, 5) + + row[0] = table.NewValueString(s.DirectoryPath + "/" + s.ContentPath) + + if len(s.UncommitedChanges) != 0 { + row[1] = table.NewValueInt(len(s.UncommitedChanges)) + } + + if len(s.LocalCsets) != 0 { + row[2] = table.NewValueInt(len(s.LocalCsets)) + } + + if s.MatchTarget() { + row[3] = table.NewValueFmt(table.NewValueString("up-to-date"), false) + } else { + row[3] = table.NewValueFmt(table.NewValueString("mismatch"), true) + } + + if s.IsSafe() { + row[4] = table.NewValueFmt(table.NewValueString("safe"), false) + } else { + row[4] = table.NewValueFmt(table.NewValueString("not safe"), true) + } + + return row +} + +type StatusList []*Status + +func (sm StatusList) String() string { + var s string + + s += "Detailed status:\n" + for _, status := range sm { + s += "- " + status.DirectoryPath + "/" + status.ContentPath + ": " + status.String() + "\n" + } + + if !sm.IsSafe() { + s += "\n /!\\ At least one directory is not clean /!\\\n" + } + + return s +} + +func (sm StatusList) Table() table.Table { + t := table.Table{ + Title: "Detailled status", + Header: []table.Header{{ + Key: "path", + Title: "path", + }, { + Key: "changes", + Title: "changes", + }, { + Key: "commits", + Title: "commits", + }, { + Key: "match", + Title: "match", + }, { + Key: "safe", + Title: "safe", + }}, + } + + for _, status := range sm { + t.Rows = append(t.Rows, status.toRow()) + } + + return t +} + +func (sm StatusList) IsSafe() bool { + isSafe := true + for _, status := range sm { + if !status.IsSafe() { + isSafe = false + break + } + } + + return isSafe +}