diff --git a/cli/app/diff.go b/cli/app/diff.go new file mode 100644 index 00000000..e6566fcb --- /dev/null +++ b/cli/app/diff.go @@ -0,0 +1,51 @@ +package app + +import ( + "fmt" + "path/filepath" + + "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/autocomplete" + gitPkg "coopcloud.tech/abra/pkg/git" + "coopcloud.tech/abra/pkg/log" + "github.com/spf13/cobra" +) + +var AppDiffCommand = &cobra.Command{ + Use: "diff [flags]", + Aliases: []string{"df"}, + Short: "Show diff of app env changes", + Long: `This command requires /usr/bin/git.`, + Example: " abra app diff 1312.net", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func( + cmd *cobra.Command, + args []string, + toComplete string) ([]string, cobra.ShellCompDirective) { + return autocomplete.AppNameComplete() + }, + Run: func(cmd *cobra.Command, args []string) { + app := internal.ValidateApp(args) + + gitDir := gitPkg.FindDir(app.Path) + if gitDir == "" { + log.Fatal(fmt.Errorf("no git repo found for %s", app.Name)) + } + + fpath := app.Path + realPath, err := filepath.EvalSymlinks(fpath) + if err != nil { + log.Fatalf("unable to app env: broken symlink: %s", fpath) + } + fpath = realPath + + diff, err := gitPkg.DiffUnstaged(gitDir, fpath) + if err != nil { + log.Fatalf("unable to diff %s: %s", app.Name, err) + } + + if diff != "" { + fmt.Print(diff) + } + }, +} diff --git a/cli/app/push.go b/cli/app/push.go new file mode 100644 index 00000000..84e83eff --- /dev/null +++ b/cli/app/push.go @@ -0,0 +1,110 @@ +package app + +import ( + "fmt" + "path/filepath" + + "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/autocomplete" + gitPkg "coopcloud.tech/abra/pkg/git" + "coopcloud.tech/abra/pkg/log" + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" +) + +var AppPushCommand = &cobra.Command{ + Use: "push [flags]", + Aliases: []string{"pu"}, + Short: "Push app changes to a remote", + Long: `Run "abra app pull " beforehand to reduce conflicts.`, + Example: "abra app push 1312.net", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func( + cmd *cobra.Command, + args []string, + toComplete string) ([]string, cobra.ShellCompDirective) { + return autocomplete.AppNameComplete() + }, + Run: func(cmd *cobra.Command, args []string) { + app := internal.ValidateApp(args) + + gitDir := gitPkg.FindDir(app.Path) + if gitDir == "" { + log.Fatal(fmt.Errorf("no git repo found for %s", app.Name)) + } + + appDir := filepath.Dir(app.Path) + log.Infof("%s currently has these unstaged changes 👇", app.Name) + diff, err := gitPkg.DiffUnstaged(appDir, app.Path) + if err != nil { + log.Fatal(err) + } + + if diff == "" { + log.Infof("no diff for %s, nothing to push", app.Name) + return + } + + fmt.Print(diff) + + var confirmPush bool + if !internal.NoInput { + prompt := &survey.Confirm{ + Message: "push these changes?", + } + + if err := survey.AskOne(prompt, &confirmPush); err != nil { + log.Fatal(err) + } + } + + if msg == "" && !internal.NoInput { + prompt := &survey.Input{ + Message: "commit message?", + } + + if err := survey.AskOne(prompt, &msg); err != nil { + log.Fatal(err) + } + } + + if msg == "" { + log.Fatal("missing --msg/-m") + } + + if confirmPush || internal.NoInput { + fname := filepath.Base(app.Path) + if err := gitPkg.CommitFile(gitDir, fname, msg, internal.Dry); err != nil { + log.Fatal(err) + } + + if err := gitPkg.Push(gitDir, "origin", false, internal.Dry); err != nil { + log.Fatal(err) + } + + log.Info("changes pushed successfully 🦋") + } + }, +} + +var ( + msg string +) + +func init() { + AppPushCommand.Flags().StringVarP( + &msg, + "msg", + "m", + "", + "commit message", + ) + + AppPushCommand.Flags().BoolVarP( + &internal.Dry, + "dry-run", + "r", + false, + "report changes that would be made", + ) +} diff --git a/cli/recipe/diff.go b/cli/recipe/diff.go index b8bdfd67..907a0ac8 100644 --- a/cli/recipe/diff.go +++ b/cli/recipe/diff.go @@ -1,6 +1,8 @@ package recipe import ( + "fmt" + "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/autocomplete" gitPkg "coopcloud.tech/abra/pkg/git" @@ -22,8 +24,12 @@ var RecipeDiffCommand = &cobra.Command{ }, Run: func(cmd *cobra.Command, args []string) { r := internal.ValidateRecipe(args, cmd.Name()) - if err := gitPkg.DiffUnstaged(r.Dir); err != nil { + diff, err := gitPkg.DiffUnstaged(r.Dir, "") + if err != nil { log.Fatal(err) } + if diff != "" { + fmt.Print(diff) + } }, } diff --git a/cli/recipe/release.go b/cli/recipe/release.go index 660de687..ab57e732 100644 --- a/cli/recipe/release.go +++ b/cli/recipe/release.go @@ -118,9 +118,13 @@ your SSH keys configured on your account.`, if !isClean { log.Infof("%s currently has these unstaged changes 👇", recipe.Name) - if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil { + diff, err := gitPkg.DiffUnstaged(recipe.Dir, "") + if err != nil { log.Fatal(err) } + if diff != "" { + fmt.Print(diff) + } } if len(tags) > 0 { diff --git a/cli/recipe/sync.go b/cli/recipe/sync.go index 73bc55bf..77016f3e 100644 --- a/cli/recipe/sync.go +++ b/cli/recipe/sync.go @@ -208,9 +208,13 @@ likely to change. } if !isClean { log.Infof("%s currently has these unstaged changes 👇", recipe.Name) - if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil { + diff, err := gitPkg.DiffUnstaged(recipe.Dir, "") + if err != nil { log.Fatal(err) } + if diff != "" { + fmt.Print(diff) + } } }, } diff --git a/cli/recipe/upgrade.go b/cli/recipe/upgrade.go index de75048e..0cf632ae 100644 --- a/cli/recipe/upgrade.go +++ b/cli/recipe/upgrade.go @@ -327,9 +327,13 @@ You may invoke this command in "wizard" mode and be prompted for input.`, } if !isClean { log.Infof("%s currently has these unstaged changes 👇", recipe.Name) - if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil { + diff, err := gitPkg.DiffUnstaged(recipe.Dir, "") + if err != nil { log.Fatal(err) } + if diff != "" { + fmt.Print(diff) + } } }, } diff --git a/cli/run.go b/cli/run.go index 70d89f05..9231e33f 100644 --- a/cli/run.go +++ b/cli/run.go @@ -187,9 +187,11 @@ func Run(version, commit string) { app.AppUndeployCommand, app.AppUpgradeCommand, app.AppVolumeCommand, + app.AppDiffCommand, + app.AppPushCommand, ) if err := rootCmd.Execute(); err != nil { - log.Fatal(err) + os.Exit(1) } } diff --git a/pkg/config/env.go b/pkg/config/env.go index 68a1d1c1..32690c74 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -63,7 +63,7 @@ func GetAllFilesInDirectory(directory string) ([]fs.FileInfo, error) { realPath, err := filepath.EvalSymlinks(filePath) if err != nil { - log.Warnf("broken symlink in your abra config folders: %s", filePath) + log.Warnf("broken symlink in your $ABRA_DIR: %s", filePath) } else { realFile, err := os.Stat(realPath) if err != nil { diff --git a/pkg/git/commit.go b/pkg/git/commit.go index 7775e518..6b0c34f5 100644 --- a/pkg/git/commit.go +++ b/pkg/git/commit.go @@ -45,3 +45,39 @@ func Commit(repoPath, commitMessage string, dryRun bool) error { return nil } + +// CommitFile commits a specific file. +func CommitFile(repoPath, filePath, commitMessage string, dryRun bool) error { + if commitMessage == "" { + return fmt.Errorf("no commit message specified?") + } + + commitRepo, err := git.PlainOpen(repoPath) + if err != nil { + return err + } + + commitWorktree, err := commitRepo.Worktree() + if err != nil { + return err + } + + if !dryRun { + if _, err := commitWorktree.Add(filePath); err != nil { + return fmt.Errorf("unable to add %s: %s", filePath, err) + } + } + + opts := &git.CommitOptions{} + if !dryRun { + _, err = commitWorktree.Commit(commitMessage, opts) + if err != nil { + return err + } + log.Debug("git changes commited") + } else { + log.Debug("dry run: no changes commited") + } + + return nil +} diff --git a/pkg/git/diff.go b/pkg/git/diff.go index 6242d654..eae5a8a1 100644 --- a/pkg/git/diff.go +++ b/pkg/git/diff.go @@ -3,6 +3,7 @@ package git import ( "fmt" "os/exec" + "strings" "coopcloud.tech/abra/pkg/log" ) @@ -10,8 +11,8 @@ import ( // getGitDiffArgs builds the `git diff` invocation args. It removes the usage // of a pager and ensures that colours are specified even when Git might detect // otherwise. -func getGitDiffArgs(repoPath string) []string { - return []string{ +func getGitDiffArgs(repoPath, fname string) []string { + args := []string{ "-C", repoPath, "--no-pager", @@ -19,24 +20,29 @@ func getGitDiffArgs(repoPath string) []string { "color.diff=always", "diff", } + + if fname != "" { + args = append(args, fname) + } + + return args } // DiffUnstaged shows a `git diff`. Due to limitations in the underlying go-git -// library, this implementation requires the /usr/bin/git binary. It gracefully -// skips if it cannot find the command on the system. -func DiffUnstaged(path string) error { +// library, this implementation requires the /usr/bin/git binary. +func DiffUnstaged(path, fname string) (string, error) { if _, err := exec.LookPath("git"); err != nil { - log.Warnf("unable to locate git command, cannot output diff") - return nil + return "", fmt.Errorf("missing /usr/bin/git command? cannot output diff") } - gitDiffArgs := getGitDiffArgs(path) + gitDiffArgs := getGitDiffArgs(path, fname) + + log.Debugf("running: git %s", strings.Join(gitDiffArgs, " ")) + diff, err := exec.Command("git", gitDiffArgs...).Output() if err != nil { - return nil + return "", err } - fmt.Print(string(diff)) - - return nil + return string(diff), nil } diff --git a/pkg/git/dir.go b/pkg/git/dir.go new file mode 100644 index 00000000..98f51381 --- /dev/null +++ b/pkg/git/dir.go @@ -0,0 +1,35 @@ +package git + +import ( + "os" + "path" + "path/filepath" + + "coopcloud.tech/abra/pkg/log" +) + +func FindDir(dir string) string { + dir, err := filepath.Abs(dir) + if err != nil { + return "" + } + + realPath, err := filepath.EvalSymlinks(dir) + if err != nil { + log.Warn("unable to find git repo: broken symlink: %s", dir) + return "" + } + + dir = realPath + + if dir == os.ExpandEnv("$HOME/.abra") || dir == os.ExpandEnv("$ABRA_DIR") || dir == "/" { + return "" + } + + p := path.Join(dir, ".git") + if _, err := os.Stat(p); err == nil { + return path.Dir(p) + } + + return FindDir(filepath.Dir(dir)) +} diff --git a/pkg/git/pull.go b/pkg/git/pull.go new file mode 100644 index 00000000..fa5dbfbd --- /dev/null +++ b/pkg/git/pull.go @@ -0,0 +1,41 @@ +package git + +import ( + "errors" + + "coopcloud.tech/abra/pkg/log" + "github.com/go-git/go-git/v5" +) + +// Pull pulls the latest changes in. +func Pull(repoDir string, dryRun bool) error { + if dryRun { + log.Debugf("dry run: no git changes pulled in %s", repoDir) + return nil + } + + repo, err := git.PlainOpen(repoDir) + if err != nil { + return err + } + + opts := &git.PullOptions{ + RemoteName: "origin", // NOTE(d1): what could go wrong 🤡 + } + + worktree, err := repo.Worktree() + if err != nil { + return err + } + + if err := worktree.Pull(opts); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { + return err + } else if err != nil && errors.Is(err, git.NoErrAlreadyUpToDate) { + log.Debugf("skipping pulling changes at %s", repoDir) + return nil + } + + log.Debugf("git changes pulled in at %s", repoDir) + + return nil +} diff --git a/pkg/git/push.go b/pkg/git/push.go index 9d279e65..d27fc3dc 100644 --- a/pkg/git/push.go +++ b/pkg/git/push.go @@ -27,7 +27,7 @@ func Push(repoDir string, remote string, tags bool, dryRun bool) error { return err } - log.Debugf("git changes pushed") + log.Debug("git changes pushed") if tags { opts.RefSpecs = append(opts.RefSpecs, config.RefSpec("+refs/tags/*:refs/tags/*")) @@ -36,7 +36,7 @@ func Push(repoDir string, remote string, tags bool, dryRun bool) error { return err } - log.Debugf("git tags pushed") + log.Debug("git tags pushed") } return nil