diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0740e43..ce0e3fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,5 +36,11 @@ jobs: - name: lint run: make lint + - name: setup git + run: |- + git config --global user.email foo@example.com && \ + git config --global user.name "Foo Bar" && \ + git config --global init.defaultBranch main + - name: test run: make test diff --git a/Makefile b/Makefile index ca4d825..faec5af 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ build: .PHONY: test test: - go test -v . + go test -v ./... GOLANGCI_LINT_VERSION=v1.61.0 GOLANGCI_LINT = $(shell go env GOPATH)/bin/golangci-lint diff --git a/core/help.go b/core/help.go index 0893485..ec1aa9e 100644 --- a/core/help.go +++ b/core/help.go @@ -19,7 +19,7 @@ func Help() { * Use interactive mode for command which requires input such as "npx sv create" for SvelteKit. git exec -i npx sv create my-app ` - optionItems, envVarItems := opts.HelpItemsAndEnvVarMappings[Options](defaultOptions, optionTypes) + optionItems, envVarItems := opts.HelpItemsAndEnvVarMappings[Options](DefaultOptions, optionTypes) options := "Options:\n" + strings.Join(optionItems, "\n") envVars := "Environment variable mapping:\n" + strings.Join(envVarItems, "\n") diff --git a/core/options.go b/core/options.go index acbb548..80f7572 100644 --- a/core/options.go +++ b/core/options.go @@ -19,7 +19,7 @@ type Options struct { Interactive bool } -var defaultOptions = &Options{ +var DefaultOptions = &Options{ Help: false, Version: false, Directory: "", @@ -79,9 +79,9 @@ var optionTypes = func() []*opts.Definition[Options] { }() func newOptions() *Options { - return opts.NewOptions(optionTypes, defaultOptions) + return opts.NewOptions(optionTypes, DefaultOptions) } func ParseOptions(args []string) (*Options, []string, error) { - return opts.Parse(defaultOptions, optionTypes, args...) + return opts.Parse(DefaultOptions, optionTypes, args...) } diff --git a/testdir/git.go b/testdir/git.go new file mode 100644 index 0000000..017b097 --- /dev/null +++ b/testdir/git.go @@ -0,0 +1,58 @@ +package testdir + +import ( + "os" + "path/filepath" + "testing" +) + +// GitRoot searches for the directory containing the .git directory starting from the current directory and moving upwards. +// If the .git directory is found, it returns the path to that directory. +// If the .git directory is not found, it fails the test. +func GitRoot(t *testing.T) string { + dir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir + } + + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf(".git directory not found") + } + dir = parent + } +} + +// FromGitRoot returns the absolute path by joining the Git root directory with the provided relative path. +// If the .git directory is not found, it fails the test. +func FromGitRoot(t *testing.T, relpath string) string { + return filepath.Join(GitRoot(t), relpath) +} + +func GoModRoot(t *testing.T) string { + dir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("go.mod file not found") + } + dir = parent + } +} + +func FromGoModRoot(t *testing.T, relpath string) string { + return filepath.Join(GoModRoot(t), relpath) +} diff --git a/testdir/setup.go b/testdir/setup.go new file mode 100644 index 0000000..efd9a77 --- /dev/null +++ b/testdir/setup.go @@ -0,0 +1,107 @@ +package testdir + +import ( + "io" + "io/fs" + "os" + "path/filepath" + "testing" +) + +func Setup(t testing.TB, srcDir, destDir string) func() { + // srcDir を destDir にコピーして、コピーされたディレクトリにカレントディレクトリを移動する + // 戻り値は カレントディレクトリを元のディレクトリに戻し、コピーされたディレクトリを削除する関数 + t.Logf("srcDir: %s\n", srcDir) + t.Logf("destDir: %s\n", destDir) + + absSrcDir, err := filepath.Abs(srcDir) + if err != nil { + t.Fatalf("Failed to get absolute path: %v", err) + } + t.Logf("absSrcDir: %s\n", absSrcDir) + + groundDir := filepath.Join(destDir, filepath.Base(absSrcDir)) // srcDir に . が指定された場合でもそのディレクトリ名を取得する + t.Logf("groundDir: %s\n", groundDir) + if err := os.MkdirAll(destDir, os.ModePerm); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + // Copy srcDir to destDir + copyDir(t, srcDir, groundDir) + + // Save the current working directory + origWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + + // Change to the new directory + if err := os.Chdir(groundDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Return a function to restore the original directory and clean up + return func() { + // Change back to the original directory + if err := os.Chdir(origWd); err != nil { + t.Fatalf("Failed to change back to original directory: %v", err) + } + + // Remove the copied directory + if err := os.RemoveAll(groundDir); err != nil { + t.Fatalf("Failed to remove directory %s: %v", groundDir, err) + } + } +} + +// Helper function to copy a directory +func copyDir(t testing.TB, src string, dest string) { + t.Logf("copyDir: %s -> %s\n", src, dest) + + entries, err := os.ReadDir(src) + if err != nil { + t.Fatalf("Failed to read directory: %v", err) + } + + err = os.MkdirAll(dest, os.ModePerm) + if err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + for _, entry := range entries { + copyEntry(t, src, dest, entry) + } +} + +func copyEntry(t testing.TB, src string, dest string, entry fs.DirEntry) { + if entry.Name() == ".git" { + t.Logf("skip .git directory\n") + return + } + + srcPath := filepath.Join(src, entry.Name()) + destPath := filepath.Join(dest, entry.Name()) + + if entry.IsDir() { + copyDir(t, srcPath, destPath) + return + } + + t.Logf("copyEntry: %s -> %s\n", srcPath, destPath) + + reader, err := os.Open(srcPath) + if err != nil { + t.Fatalf("Failed to open source file: %v", err) + } + defer reader.Close() + + writer, err := os.Create(destPath) + if err != nil { + t.Fatalf("Failed to create destination file: %v", err) + } + defer writer.Close() + + if _, err := io.Copy(writer, reader); err != nil { + t.Fatalf("Failed to copy file: %v", err) + } +} diff --git a/testexec/cmd.go b/testexec/cmd.go new file mode 100644 index 0000000..75b0f5b --- /dev/null +++ b/testexec/cmd.go @@ -0,0 +1,29 @@ +package testexec + +import ( + "os" + "os/exec" + "testing" +) + +func Run(t testing.TB, command string, args ...string) { + t.Helper() + + cmd := exec.Command(command, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatalf("failed to run %s %v: %s", command, args, err) + } +} + +func Stdout(t testing.TB, command string, args ...string) string { + t.Helper() + + cmd := exec.Command(command, args...) + out, err := cmd.Output() + if err != nil { + t.Fatalf("failed to run %s %v: %s", command, args, err) + } + return string(out) +} diff --git a/tests/grounds/.gitignore b/tests/grounds/.gitignore new file mode 100644 index 0000000..4c70690 --- /dev/null +++ b/tests/grounds/.gitignore @@ -0,0 +1,4 @@ +* +.* +*.* +!.gitignore diff --git a/tests/scenes/01_basic/Makefile b/tests/scenes/01_basic/Makefile new file mode 100644 index 0000000..a3aa6e8 --- /dev/null +++ b/tests/scenes/01_basic/Makefile @@ -0,0 +1,17 @@ +README.md: + @echo "Generating README.md" + @echo "This is a test" > README.md + +WORK_FILE=work.txt + +.PHONY: add-one +add-one: + @echo "One" >> $(WORK_FILE) + +.PHONY: add-two +add-two: + @echo "Two" >> $(WORK_FILE) + +.PHONY: add-three +add-three: + @echo "Three" >> $(WORK_FILE) diff --git a/tests/scenes/01_basic/aliases.go b/tests/scenes/01_basic/aliases.go new file mode 100644 index 0000000..2eb12dc --- /dev/null +++ b/tests/scenes/01_basic/aliases.go @@ -0,0 +1,10 @@ +package basic + +import ( + "github.com/akm/git-exec/testexec" +) + +var ( + run = testexec.Run + stdout = testexec.Stdout +) diff --git a/tests/scenes/01_basic/commit_message_test.go b/tests/scenes/01_basic/commit_message_test.go new file mode 100644 index 0000000..74e935f --- /dev/null +++ b/tests/scenes/01_basic/commit_message_test.go @@ -0,0 +1,28 @@ +package basic + +import ( + "testing" + + "github.com/akm/git-exec/core" + "github.com/akm/git-exec/tests/testground" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommitMessage(t *testing.T) { + defer testground.Setup(t)() + + opts := *core.DefaultOptions + opts.Emoji = "🏭️" + opts.Prompt = "%" + opts.Template = `{{.Emoji}} {{.Prompt}} {{.Command}} [at {{.Location}}]` + err := core.Run(&opts, []string{"make", "README.md"}) + require.NoError(t, err) + + commitMessage := stdout(t, "git", "log", "-1", "--pretty=%B") + assert.Equal(t, `🏭️ % make README.md [at 01_basic] + +Generating README.md + +`, commitMessage) +} diff --git a/tests/scenes/01_basic/guard_test.go b/tests/scenes/01_basic/guard_test.go new file mode 100644 index 0000000..233dd50 --- /dev/null +++ b/tests/scenes/01_basic/guard_test.go @@ -0,0 +1,45 @@ +package basic + +import ( + "strings" + "testing" + + "github.com/akm/git-exec/core" + "github.com/akm/git-exec/tests/testground" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGuardUntrackedFiles(t *testing.T) { + defer testground.Setup(t)() + defer testground.AssertStringNotChanged(t, testground.GitLastCommitHash)() + + run(t, "make", "add-one") // Let it not be committed + + err := core.Run(core.DefaultOptions, []string{"make", "README.md"}) + require.Error(t, err) + assert.Equal(t, `Quit processing because There are untracked files + +Untracked files: +work.txt`, err.Error()) +} + +func TestGuardUncommittedChanes(t *testing.T) { + defer testground.Setup(t)() + + // commit add-one + run(t, "make", "add-one") + run(t, "git", "add", ".") + run(t, "git", "commit", "-m", "add one") + + defer testground.AssertStringNotChanged(t, testground.GitLastCommitHash)() + run(t, "make", "add-two") // Let it not be committed + + err := core.Run(core.DefaultOptions, []string{"make", "README.md"}) + require.Error(t, err) + assert.Equal(t, `Quit processing because There are uncommitted changes + +Uncommitted changes: +`+strings.TrimSpace(stdout(t, "git", "diff")), + err.Error()) +} diff --git a/tests/scenes/01_basic/happy_path_test.go b/tests/scenes/01_basic/happy_path_test.go new file mode 100644 index 0000000..2c7d04c --- /dev/null +++ b/tests/scenes/01_basic/happy_path_test.go @@ -0,0 +1,25 @@ +package basic + +import ( + "testing" + + "github.com/akm/git-exec/core" + "github.com/akm/git-exec/tests/testground" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHappyPath(t *testing.T) { + defer testground.Setup(t)() + + // run(t, "make", "README.md") + err := core.Run(core.DefaultOptions, []string{"make", "README.md"}) + require.NoError(t, err) + + commitMessage := stdout(t, "git", "log", "-1", "--pretty=%B") + assert.Equal(t, `🤖 [01_basic] $ make README.md + +Generating README.md + +`, commitMessage) +} diff --git a/tests/scenes/01_basic/no_change_test.go b/tests/scenes/01_basic/no_change_test.go new file mode 100644 index 0000000..fe28045 --- /dev/null +++ b/tests/scenes/01_basic/no_change_test.go @@ -0,0 +1,24 @@ +package basic + +import ( + "testing" + + "github.com/akm/git-exec/core" + "github.com/akm/git-exec/tests/testground" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNoChange(t *testing.T) { + defer testground.Setup(t)() + + run(t, "make", "README.md") + run(t, "git", "add", ".") + run(t, "git", "commit", "-m", "$ make README.md") + + defer testground.AssertStringNotChanged(t, testground.GitLastCommitHash)() + + err := core.Run(core.DefaultOptions, []string{"make", "README.md"}) + require.Error(t, err) + assert.Equal(t, "No changes to commit and No untracked files", err.Error()) +} diff --git a/tests/scenes/02_subdir/Makefile b/tests/scenes/02_subdir/Makefile new file mode 100644 index 0000000..a3aa6e8 --- /dev/null +++ b/tests/scenes/02_subdir/Makefile @@ -0,0 +1,17 @@ +README.md: + @echo "Generating README.md" + @echo "This is a test" > README.md + +WORK_FILE=work.txt + +.PHONY: add-one +add-one: + @echo "One" >> $(WORK_FILE) + +.PHONY: add-two +add-two: + @echo "Two" >> $(WORK_FILE) + +.PHONY: add-three +add-three: + @echo "Three" >> $(WORK_FILE) diff --git a/tests/scenes/02_subdir/aliases.go b/tests/scenes/02_subdir/aliases.go new file mode 100644 index 0000000..99a71b8 --- /dev/null +++ b/tests/scenes/02_subdir/aliases.go @@ -0,0 +1,10 @@ +package basic + +import ( + "github.com/akm/git-exec/testexec" +) + +var ( + // run = testexec.Run + stdout = testexec.Stdout +) diff --git a/tests/scenes/02_subdir/directory_option_test.go b/tests/scenes/02_subdir/directory_option_test.go new file mode 100644 index 0000000..9733af4 --- /dev/null +++ b/tests/scenes/02_subdir/directory_option_test.go @@ -0,0 +1,26 @@ +package basic + +import ( + "testing" + + "github.com/akm/git-exec/core" + "github.com/akm/git-exec/tests/testground" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDirectoryOption(t *testing.T) { + defer testground.Setup(t)() + + defer testground.AssertStringNotChanged(t, testground.GitDiff)() + + opts := *core.DefaultOptions + opts.Directory = "sub1" + err := core.Run(&opts, []string{"make", "add-one", "parent-add-two"}) + require.NoError(t, err) + + commitMessage := stdout(t, "git", "log", "-1", "--pretty=%B") + assert.Equal(t, `🤖 [02_subdir] $ make add-one parent-add-two + +`, commitMessage) +} diff --git a/tests/scenes/02_subdir/sub1/Makefile b/tests/scenes/02_subdir/sub1/Makefile new file mode 100644 index 0000000..cfe7206 --- /dev/null +++ b/tests/scenes/02_subdir/sub1/Makefile @@ -0,0 +1,13 @@ +WORK_FILE=work.txt + +.PHONY: add-one +add-one: + @echo "One" >> $(WORK_FILE) + +.PHONY: add-two +add-two: + @echo "Two" >> $(WORK_FILE) + +.PHONY: parent-% +parent-%: + @$(MAKE) -C .. $* diff --git a/tests/testground/assertions.go b/tests/testground/assertions.go new file mode 100644 index 0000000..92ece7c --- /dev/null +++ b/tests/testground/assertions.go @@ -0,0 +1,16 @@ +package testground + +import "testing" + +func AssertStringNotChanged(t *testing.T, getter func(t *testing.T) string) func() { + t.Helper() + before := getter(t) + + return func() { + t.Helper() + after := getter(t) + if before != after { + t.Fatalf("expected %q, but got %q", before, after) + } + } +} diff --git a/tests/testground/git.go b/tests/testground/git.go new file mode 100644 index 0000000..129a25e --- /dev/null +++ b/tests/testground/git.go @@ -0,0 +1,17 @@ +package testground + +import ( + "testing" + + "github.com/akm/git-exec/testexec" +) + +func GitLastCommitHash(t *testing.T) string { + t.Helper() + return testexec.Stdout(t, "git", "rev-parse", "HEAD") +} + +func GitDiff(t *testing.T) string { + t.Helper() + return testexec.Stdout(t, "git", "diff") +} diff --git a/tests/testground/setup.go b/tests/testground/setup.go new file mode 100644 index 0000000..44cd1f3 --- /dev/null +++ b/tests/testground/setup.go @@ -0,0 +1,23 @@ +package testground + +import ( + "os" + "testing" + + "github.com/akm/git-exec/testdir" + "github.com/akm/git-exec/testexec" +) + +func Setup(t *testing.T) func() { + t.Helper() + + // Suppress make's output + os.Setenv("MAKEFLAGS", "--no-print-directory") + + r := testdir.Setup(t, ".", testdir.FromGoModRoot(t, "tests/grounds")) + testexec.Run(t, "git", "init") + testexec.Run(t, "git", "add", ".") + testexec.Run(t, "git", "commit", "-m", "Initial commit") + + return r +}