diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index ba40dfa..5363b11 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -5,9 +5,13 @@ import ( "os" "path/filepath" "testing" + "time" "git.hrafn.xyz/aether/gosick/internal/homesick/cli" "git.hrafn.xyz/aether/gosick/internal/homesick/version" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -32,6 +36,32 @@ func (s *CLISuite) SetupTest() { s.stderr = &bytes.Buffer{} } +func (s *CLISuite) createCastleRepo(castle string) string { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", castle) + repo, err := git.PlainInit(castleRoot, false) + require.NoError(s.T(), err) + + filePath := filepath.Join(castleRoot, "home", ".vimrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755)) + require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.vimrc") + require.NoError(s.T(), err) + _, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{ + Name: "Behavior Test", + Email: "behavior@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) + + _, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{"git://example.com/test.git"}}) + require.NoError(s.T(), err) + + return castleRoot +} + func (s *CLISuite) TestRun_VersionAliases() { for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} { s.stdout.Reset() @@ -191,3 +221,52 @@ func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() { require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit") require.Empty(s.T(), s.stderr.String()) } + +func (s *CLISuite) TestRun_List_NoArguments() { + s.createCastleRepo("dotfiles") + + exitCode := cli.Run([]string{"list"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), "dotfiles") + require.Empty(s.T(), s.stderr.String()) +} + +func (s *CLISuite) TestRun_Generate_CreatesNewCastle() { + castlePath := filepath.Join(s.T().TempDir(), "my-castle") + + exitCode := cli.Run([]string{"generate", castlePath}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.DirExists(s.T(), filepath.Join(castlePath, ".git")) + require.DirExists(s.T(), filepath.Join(castlePath, "home")) +} + +func (s *CLISuite) TestRun_Clone_WithoutArgs() { + exitCode := cli.Run([]string{"clone"}, s.stdout, s.stderr) + + // Clone requires arguments, should fail + require.NotEqual(s.T(), 0, exitCode) +} + +func (s *CLISuite) TestRun_Status_DefaultCastle() { + castleRoot := s.createCastleRepo("dotfiles") + require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644)) + + exitCode := cli.Run([]string{"status"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), "modified:") + require.Empty(s.T(), s.stderr.String()) +} + +func (s *CLISuite) TestRun_Diff_DefaultCastle() { + castleRoot := s.createCastleRepo("dotfiles") + require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644)) + + exitCode := cli.Run([]string{"diff"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), "diff --git") + require.Empty(s.T(), s.stderr.String()) +} diff --git a/internal/homesick/core/core_test.go b/internal/homesick/core/core_test.go index 1da9dff..0ff0220 100644 --- a/internal/homesick/core/core_test.go +++ b/internal/homesick/core/core_test.go @@ -2,6 +2,7 @@ package core import ( "bytes" + "path/filepath" "testing" ) @@ -47,3 +48,29 @@ func TestDeriveDestination(t *testing.T) { }) } } + +func TestNewInitializesApp(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + app, err := New(stdout, stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if app == nil { + t.Fatal("expected app instance") + } + + if app.Stdout != stdout { + t.Fatal("expected stdout writer to be assigned") + } + if app.Stderr != stderr { + t.Fatal("expected stderr writer to be assigned") + } + if app.HomeDir == "" { + t.Fatal("expected home directory to be set") + } + if app.ReposDir != filepath.Join(app.HomeDir, ".homesick", "repos") { + t.Fatalf("unexpected repos dir: %q", app.ReposDir) + } +} diff --git a/internal/homesick/core/exec_test.go b/internal/homesick/core/exec_test.go index 7aa284f..3f0412a 100644 --- a/internal/homesick/core/exec_test.go +++ b/internal/homesick/core/exec_test.go @@ -88,3 +88,22 @@ func (s *ExecSuite) TestExec_PretendDoesNotExecuteCommand() { require.NoFileExists(s.T(), target) require.Contains(s.T(), s.stdout.String(), "Would execute") } + +func (s *ExecSuite) TestExecAll_RequiresCommand() { + err := s.app.ExecAll(nil) + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "exec_all requires COMMAND") +} + +func (s *ExecSuite) TestExecAll_NoReposDirIsNoop() { + missingRepos := filepath.Join(s.T().TempDir(), "missing", "repos") + app := &core.App{ + HomeDir: s.homeDir, + ReposDir: missingRepos, + Stdout: s.stdout, + Stderr: s.stderr, + } + + err := app.ExecAll([]string{"echo hi"}) + require.NoError(s.T(), err) +} diff --git a/internal/homesick/core/helpers_test.go b/internal/homesick/core/helpers_test.go new file mode 100644 index 0000000..a2db849 --- /dev/null +++ b/internal/homesick/core/helpers_test.go @@ -0,0 +1,245 @@ +package core + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type errReader struct{} + +func (errReader) Read(_ []byte) (int, error) { + return 0, errors.New("boom") +} + +func TestRunGitPretendWritesStatus(t *testing.T) { + stdout := &bytes.Buffer{} + app := &App{Stdout: stdout, Stderr: bytes.NewBuffer(nil), Pretend: true} + + err := app.runGit("/tmp", "status") + require.NoError(t, err) + require.Contains(t, stdout.String(), "Would execute git status in /tmp") +} + +func TestActionVerb(t *testing.T) { + app := &App{Pretend: true} + require.Equal(t, "Would execute", app.actionVerb()) + + app.Pretend = false + require.Equal(t, "Executing", app.actionVerb()) +} + +func TestSayStatusHonorsQuiet(t *testing.T) { + stdout := &bytes.Buffer{} + app := &App{Stdout: stdout, Quiet: true} + app.sayStatus("git", "status") + require.Empty(t, stdout.String()) + + app.Quiet = false + app.sayStatus("git", "status") + require.Contains(t, stdout.String(), "git: status") +} + +func TestUnlinkPath(t *testing.T) { + t.Run("missing destination", func(t *testing.T) { + err := unlinkPath(filepath.Join(t.TempDir(), "does-not-exist")) + require.NoError(t, err) + }) + + t.Run("regular file is preserved", func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "regular") + require.NoError(t, os.WriteFile(target, []byte("x"), 0o644)) + + err := unlinkPath(target) + require.NoError(t, err) + require.FileExists(t, target) + }) + + t.Run("symlink is removed", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "dest") + require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) + require.NoError(t, os.Symlink(source, destination)) + + err := unlinkPath(destination) + require.NoError(t, err) + _, statErr := os.Lstat(destination) + require.ErrorIs(t, statErr, os.ErrNotExist) + }) +} + +func TestLinkPath(t *testing.T) { + t.Run("existing symlink to same source is no-op", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "dest") + require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) + + absSource, err := filepath.Abs(source) + require.NoError(t, err) + require.NoError(t, os.Symlink(absSource, destination)) + + app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + err = app.linkPath(source, destination) + require.NoError(t, err) + }) + + t.Run("conflict without force errors", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "dest") + require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) + require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644)) + + app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + err := app.linkPath(source, destination) + require.Error(t, err) + require.Contains(t, err.Error(), "exists") + }) + + t.Run("force replaces existing destination", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "dest") + require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) + require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644)) + + app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil), Force: true} + err := app.linkPath(source, destination) + require.NoError(t, err) + + info, statErr := os.Lstat(destination) + require.NoError(t, statErr) + require.True(t, info.Mode()&os.ModeSymlink != 0) + }) +} + +func TestReadSubdirsAndMatchesIgnoredDir(t *testing.T) { + dir := t.TempDir() + meta := filepath.Join(dir, ".homesick_subdir") + require.NoError(t, os.WriteFile(meta, []byte(" .config/myapp \n\n"), 0o644)) + + subdirs, err := readSubdirs(meta) + require.NoError(t, err) + require.Equal(t, []string{filepath.Clean(".config/myapp")}, subdirs) + + castleHome := filepath.Join(dir, "castle", "home") + candidate := filepath.Join(castleHome, ".config") + ignored, err := matchesIgnoredDir(castleHome, candidate, subdirs) + require.NoError(t, err) + require.True(t, ignored) + + notIgnored, err := matchesIgnoredDir(castleHome, filepath.Join(castleHome, ".vim"), subdirs) + require.NoError(t, err) + require.False(t, notIgnored) +} + +func TestPullAndPushDefaultCastlePretend(t *testing.T) { + dir := t.TempDir() + stdout := &bytes.Buffer{} + app := &App{ + HomeDir: dir, + ReposDir: filepath.Join(dir, ".homesick", "repos"), + Stdout: stdout, + Stderr: bytes.NewBuffer(nil), + Pretend: true, + } + + require.NoError(t, app.Pull("")) + require.NoError(t, app.Push("")) + + out := stdout.String() + require.Contains(t, out, "git pull") + require.Contains(t, out, "git push") + require.Contains(t, out, filepath.Join(app.ReposDir, "dotfiles")) +} + +func TestGenerateRequiresPath(t *testing.T) { + app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + err := app.Generate(" ") + require.Error(t, err) + require.Contains(t, err.Error(), "generate requires PATH") +} + +func TestLinkAndUnlinkDefaultCastle(t *testing.T) { + dir := t.TempDir() + homeDir := filepath.Join(dir, "home") + reposDir := filepath.Join(homeDir, ".homesick", "repos") + + castleHome := filepath.Join(reposDir, "dotfiles", "home") + require.NoError(t, os.MkdirAll(castleHome, 0o755)) + source := filepath.Join(castleHome, ".vimrc") + require.NoError(t, os.WriteFile(source, []byte("set number\n"), 0o644)) + + app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + require.NoError(t, app.Link("")) + + destination := filepath.Join(homeDir, ".vimrc") + info, err := os.Lstat(destination) + require.NoError(t, err) + require.True(t, info.Mode()&os.ModeSymlink != 0) + + require.NoError(t, app.Unlink("")) + _, err = os.Lstat(destination) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestLinkAndUnlinkCastleMissingError(t *testing.T) { + dir := t.TempDir() + app := &App{ + HomeDir: filepath.Join(dir, "home"), + ReposDir: filepath.Join(dir, "home", ".homesick", "repos"), + Stdout: bytes.NewBuffer(nil), + Stderr: bytes.NewBuffer(nil), + } + + err := app.LinkCastle("missing") + require.Error(t, err) + require.Contains(t, err.Error(), "could not symlink") + + err = app.UnlinkCastle("missing") + require.Error(t, err) + require.Contains(t, err.Error(), "could not symlink") +} + +func TestConfirmDestroyResponses(t *testing.T) { + stdout := &bytes.Buffer{} + app := &App{Stdout: stdout, Stdin: strings.NewReader("yes\n")} + + ok, err := app.confirmDestroy("dotfiles") + require.NoError(t, err) + require.True(t, ok) + require.Contains(t, stdout.String(), "Destroy castle \"dotfiles\"?") + + stdout.Reset() + app.Stdin = strings.NewReader("n\n") + ok, err = app.confirmDestroy("dotfiles") + require.NoError(t, err) + require.False(t, ok) +} + +func TestConfirmDestroyReadError(t *testing.T) { + app := &App{Stdout: bytes.NewBuffer(nil), Stdin: errReader{}} + ok, err := app.confirmDestroy("dotfiles") + require.Error(t, err) + require.False(t, ok) +} + +func TestExecAllWrapsCastleError(t *testing.T) { + dir := t.TempDir() + homeDir := filepath.Join(dir, "home") + reposDir := filepath.Join(homeDir, ".homesick", "repos") + require.NoError(t, os.MkdirAll(filepath.Join(reposDir, "broken", ".git"), 0o755)) + + app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + err := app.ExecAll([]string{"exit 3"}) + require.Error(t, err) + require.Contains(t, err.Error(), "exec_all failed for \"broken\"") +} diff --git a/internal/homesick/core/pull_test.go b/internal/homesick/core/pull_test.go index eacd224..82234e0 100644 --- a/internal/homesick/core/pull_test.go +++ b/internal/homesick/core/pull_test.go @@ -140,3 +140,17 @@ func (s *PullSuite) TestPullAll_PrintsCastlePrefixes() { require.Contains(s.T(), stdout.String(), "alpha:") require.Contains(s.T(), stdout.String(), "zeta:") } + +func (s *PullSuite) TestPullAll_QuietSuppressesCastlePrefixes() { + require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "alpha", ".git"), 0o755)) + require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "zeta", ".git"), 0o755)) + + stdout := &bytes.Buffer{} + s.app.Stdout = stdout + s.app.Quiet = true + s.app.Pretend = true + + require.NoError(s.T(), s.app.PullAll()) + require.NotContains(s.T(), stdout.String(), "alpha:") + require.NotContains(s.T(), stdout.String(), "zeta:") +} diff --git a/internal/homesick/version/version_test.go b/internal/homesick/version/version_test.go new file mode 100644 index 0000000..6070c8d --- /dev/null +++ b/internal/homesick/version/version_test.go @@ -0,0 +1,21 @@ +package version + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringConstant(t *testing.T) { + // Test that the version constant is not empty + assert.NotEmpty(t, String, "version.String should not be empty") +} + +func TestStringMatchesSemVer(t *testing.T) { + // Test that the version string matches semantic versioning pattern (major.minor.patch) + semverPattern := `^\d+\.\d+\.\d+$` + matched, err := regexp.MatchString(semverPattern, String) + assert.NoError(t, err, "regex should be valid") + assert.True(t, matched, "version.String should match semantic versioning pattern (major.minor.patch), got: %s", String) +}