From 3f9acf9295fd3394aac0f413c80d72b8cb276a11 Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Fri, 1 May 2026 10:43:03 +0100 Subject: [PATCH 1/4] test: add e2e test suite for CLI binary-level testing Adds 77 automated e2e tests that build the gitm binary and exercise it as a real user would, using isolated HOME directories and real git repos. Covers: repo add/list/remove/rename, status, branch create/rename, checkout, update, track/untrack, commit, discard, stash, reset, upgrade, help/version. Key findings documented in test output via t.Log: - checkout fails for unfetched remote-only branches - status DIRTY column counts untracked files as 'modified' - duplicate alias on repo add silently ignored --- internal/e2e/branch_test.go | 202 +++++++++++++++++++ internal/e2e/checkout_test.go | 185 +++++++++++++++++ internal/e2e/help_test.go | 95 +++++++++ internal/e2e/helpers_test.go | 302 ++++++++++++++++++++++++++++ internal/e2e/interactive_test.go | 112 +++++++++++ internal/e2e/repo_test.go | 328 +++++++++++++++++++++++++++++++ internal/e2e/status_test.go | 166 ++++++++++++++++ internal/e2e/track_test.go | 51 +++++ internal/e2e/update_test.go | 112 +++++++++++ 9 files changed, 1553 insertions(+) create mode 100644 internal/e2e/branch_test.go create mode 100644 internal/e2e/checkout_test.go create mode 100644 internal/e2e/help_test.go create mode 100644 internal/e2e/helpers_test.go create mode 100644 internal/e2e/interactive_test.go create mode 100644 internal/e2e/repo_test.go create mode 100644 internal/e2e/status_test.go create mode 100644 internal/e2e/track_test.go create mode 100644 internal/e2e/update_test.go diff --git a/internal/e2e/branch_test.go b/internal/e2e/branch_test.go new file mode 100644 index 0000000..e943cba --- /dev/null +++ b/internal/e2e/branch_test.go @@ -0,0 +1,202 @@ +package e2e + +import ( + "testing" +) + +// ========================================================================== +// Phase 3: Branch Operations (gitm branch create/rename) +// ========================================================================== + +func TestBranchCreate_WithRepo(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("bc-repo") + e.runGitm("repo", "add", repo, "--alias", "bc-repo") + + r := e.runGitm("branch", "create", "feat/test-branch", "--repo", "bc-repo") + e.assertExitCode(r, 0) + + // Verify branch was created and we're on it + branch := e.currentBranch(repo) + if branch != "feat/test-branch" { + t.Errorf("expected to be on feat/test-branch, got %s", branch) + } +} + +func TestBranchCreate_WithAll(t *testing.T) { + e := newTestEnv(t) + repo1, _ := e.initRepoWithRemote("bc-all-1") + repo2, _ := e.initRepoWithRemote("bc-all-2") + e.runGitm("repo", "add", repo1, "--alias", "bc-all-1") + e.runGitm("repo", "add", repo2, "--alias", "bc-all-2") + + r := e.runGitm("branch", "create", "feat/all-branch", "--all") + e.assertExitCode(r, 0) + + // Both repos should have the branch + if !e.branchExists(repo1, "feat/all-branch") { + t.Error("branch not created in repo1") + } + if !e.branchExists(repo2, "feat/all-branch") { + t.Error("branch not created in repo2") + } +} + +func TestBranchCreate_FromSpecificBase(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("bc-from") + e.runGitm("repo", "add", repo, "--alias", "bc-from") + + // Create a develop branch first + e.mustGit(repo, "checkout", "-b", "develop") + e.writeFile(repo, "develop.txt", "develop content\n") + e.mustGit(repo, "add", ".") + e.mustGit(repo, "commit", "-m", "develop commit") + e.mustGit(repo, "push", "--set-upstream", "origin", "develop") + e.mustGit(repo, "checkout", "main") + + r := e.runGitm("branch", "create", "feat/from-develop", "--from", "develop", "--repo", "bc-from") + e.assertExitCode(r, 0) + + // Should have the develop.txt file (branched from develop) + if !e.fileExists(repo + "/develop.txt") { + t.Error("branch was not created from develop — develop.txt missing") + } +} + +func TestBranchCreate_ExistingBranch(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("bc-existing") + e.runGitm("repo", "add", repo, "--alias", "bc-existing") + + // Create the branch first + e.mustGit(repo, "checkout", "-b", "feat/already-exists") + e.mustGit(repo, "checkout", "main") + + // gitm should check it out instead of erroring + r := e.runGitm("branch", "create", "feat/already-exists", "--repo", "bc-existing") + e.assertExitCode(r, 0) + + branch := e.currentBranch(repo) + if branch != "feat/already-exists" { + t.Errorf("expected to be on feat/already-exists, got %s", branch) + } +} + +func TestBranchCreate_FromNonExistentBase(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("bc-bad-base") + e.runGitm("repo", "add", repo, "--alias", "bc-bad-base") + + r := e.runGitm("branch", "create", "feat/x", "--from", "nonexistent-branch", "--repo", "bc-bad-base") + // Should error or show warning about base branch not found + if r.ExitCode == 0 { + // Even if exit 0, output should mention failure + combined := r.Stdout + r.Stderr + if !containsAny(combined, "not found", "error", "failed", "does not exist") { + t.Log("WARNING: creating from non-existent base succeeded silently") + } + } +} + +func TestBranchCreate_DirtyRepo(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("bc-dirty") + e.runGitm("repo", "add", repo, "--alias", "bc-dirty") + + // Make repo dirty + e.writeFile(repo, "README.md", "# dirty\n") + + r := e.runGitm("branch", "create", "feat/dirty-test", "--repo", "bc-dirty") + // Document actual behaviour: does it skip, stash, or proceed? + t.Logf("Branch create on dirty repo: exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) +} + +func TestBranchRename_WithRepo(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("br-repo") + e.runGitm("repo", "add", repo, "--alias", "br-repo") + + // Create a branch to rename + e.mustGit(repo, "checkout", "-b", "old-name") + e.mustGit(repo, "push", "--set-upstream", "origin", "old-name") + + r := e.runGitm("branch", "rename", "old-name", "new-name", "--repo", "br-repo") + e.assertExitCode(r, 0) + + // Old branch should be gone, new should exist + if e.branchExists(repo, "old-name") { + t.Error("old branch still exists after rename") + } + if !e.branchExists(repo, "new-name") { + t.Error("new branch does not exist after rename") + } +} + +func TestBranchRename_WithAll(t *testing.T) { + e := newTestEnv(t) + repo1, _ := e.initRepoWithRemote("br-all-1") + repo2, _ := e.initRepoWithRemote("br-all-2") + e.runGitm("repo", "add", repo1, "--alias", "br-all-1") + e.runGitm("repo", "add", repo2, "--alias", "br-all-2") + + // Create the same branch in both repos + e.mustGit(repo1, "checkout", "-b", "shared-old") + e.mustGit(repo1, "push", "--set-upstream", "origin", "shared-old") + e.mustGit(repo2, "checkout", "-b", "shared-old") + e.mustGit(repo2, "push", "--set-upstream", "origin", "shared-old") + + r := e.runGitm("branch", "rename", "shared-old", "shared-new", "--all") + e.assertExitCode(r, 0) + + if e.branchExists(repo1, "shared-old") { + t.Error("old branch still exists in repo1") + } + if e.branchExists(repo2, "shared-old") { + t.Error("old branch still exists in repo2") + } + if !e.branchExists(repo1, "shared-new") { + t.Error("new branch missing in repo1") + } + if !e.branchExists(repo2, "shared-new") { + t.Error("new branch missing in repo2") + } +} + +func TestBranchRename_NoRemote(t *testing.T) { + e := newTestEnv(t) + repo, origin := e.initRepoWithRemote("br-noremote") + e.runGitm("repo", "add", repo, "--alias", "br-noremote") + + e.mustGit(repo, "checkout", "-b", "local-old") + e.mustGit(repo, "push", "--set-upstream", "origin", "local-old") + + r := e.runGitm("branch", "rename", "local-old", "local-new", "--no-remote", "--repo", "br-noremote") + e.assertExitCode(r, 0) + + // Local should be renamed + if e.branchExists(repo, "local-old") { + t.Error("old branch still exists locally") + } + if !e.branchExists(repo, "local-new") { + t.Error("new branch missing locally") + } + + // Remote should still have old name (--no-remote skips remote ops) + remoteOut := e.mustGit(origin, "branch", "--list") + if !containsAny(remoteOut, "local-old") { + t.Log("Note: remote old branch was deleted even with --no-remote") + } +} + +func TestBranchRename_NonExistentBranch(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("br-ghost") + e.runGitm("repo", "add", repo, "--alias", "br-ghost") + + r := e.runGitm("branch", "rename", "nonexistent-branch", "new", "--repo", "br-ghost") + // Should skip or error — branch doesn't exist + t.Logf("Rename non-existent: exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) +} diff --git a/internal/e2e/checkout_test.go b/internal/e2e/checkout_test.go new file mode 100644 index 0000000..c16e763 --- /dev/null +++ b/internal/e2e/checkout_test.go @@ -0,0 +1,185 @@ +package e2e + +import ( + "testing" +) + +// ========================================================================== +// Phase 4: Checkout (gitm checkout) +// ========================================================================== + +func TestCheckout_DefaultBranch_Master(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("co-master") + e.runGitm("repo", "add", repo, "--alias", "co-master") + + // Create and switch to a feature branch + e.mustGit(repo, "checkout", "-b", "feat/something") + + r := e.runGitm("checkout", "master") + e.assertExitCode(r, 0) + + // Should be back on the default branch (main in our test setup) + branch := e.currentBranch(repo) + if branch != "main" { + t.Errorf("expected to be on main (default), got %s", branch) + } +} + +func TestCheckout_DefaultBranch_Main(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("co-main") + e.runGitm("repo", "add", repo, "--alias", "co-main") + + // Create and switch to a feature branch + e.mustGit(repo, "checkout", "-b", "feat/other") + + r := e.runGitm("checkout", "main") + e.assertExitCode(r, 0) + + branch := e.currentBranch(repo) + if branch != "main" { + t.Errorf("expected to be on main, got %s", branch) + } +} + +func TestCheckout_ExistingBranch_WithRepo(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("co-existing") + e.runGitm("repo", "add", repo, "--alias", "co-existing") + + // Create a feature branch + e.mustGit(repo, "checkout", "-b", "feat/target") + e.mustGit(repo, "push", "--set-upstream", "origin", "feat/target") + e.mustGit(repo, "checkout", "main") + + r := e.runGitm("checkout", "feat/target", "--repo", "co-existing") + e.assertExitCode(r, 0) + + branch := e.currentBranch(repo) + if branch != "feat/target" { + t.Errorf("expected feat/target, got %s", branch) + } +} + +func TestCheckout_NonExistentBranch(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("co-ghost") + e.runGitm("repo", "add", repo, "--alias", "co-ghost") + + r := e.runGitm("checkout", "branch-that-does-not-exist", "--repo", "co-ghost") + // Should succeed (exit 0) but skip the repo with a message + e.assertExitCode(r, 0) + // Should NOT have switched branches + branch := e.currentBranch(repo) + if branch != "main" { + t.Errorf("checkout of non-existent branch should not change current branch, but now on %s", branch) + } +} + +func TestCheckout_DirtyRepo_Skips(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("co-dirty") + e.runGitm("repo", "add", repo, "--alias", "co-dirty") + + // Create target branch + e.mustGit(repo, "checkout", "-b", "feat/dirty-target") + e.mustGit(repo, "push", "--set-upstream", "origin", "feat/dirty-target") + e.mustGit(repo, "checkout", "main") + + // Make repo dirty + e.writeFile(repo, "README.md", "# dirty content\n") + + r := e.runGitm("checkout", "feat/dirty-target", "--repo", "co-dirty") + // Should skip with warning + e.assertExitCode(r, 0) + + // Should still be on main (not switched) + branch := e.currentBranch(repo) + if branch != "main" { + t.Errorf("dirty repo should not switch branches, but now on %s", branch) + } + e.assertContains(r, "co-dirty") // Should mention the repo +} + +func TestCheckout_UntrackedFiles_ShouldNotSkip(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("co-untracked") + e.runGitm("repo", "add", repo, "--alias", "co-untracked") + + // Create target branch + e.mustGit(repo, "checkout", "-b", "feat/untracked-test") + e.mustGit(repo, "push", "--set-upstream", "origin", "feat/untracked-test") + e.mustGit(repo, "checkout", "main") + + // Add untracked file only (should NOT make repo dirty per docs) + e.writeFile(repo, "untracked-new.txt", "I am untracked\n") + + r := e.runGitm("checkout", "feat/untracked-test", "--repo", "co-untracked") + e.assertExitCode(r, 0) + + // Per docs: untracked files are ignored — checkout should proceed + branch := e.currentBranch(repo) + if branch != "feat/untracked-test" { + t.Errorf("untracked files should not block checkout, but stayed on %s", branch) + } +} + +func TestCheckout_RemoteOnlyBranch(t *testing.T) { + e := newTestEnv(t) + repo, origin := e.initRepoWithRemote("co-remote") + e.runGitm("repo", "add", repo, "--alias", "co-remote") + + // Create a branch on remote only (via another clone) + other := e.cloneRepo(origin, "co-remote-other") + e.mustGit(other, "checkout", "-b", "feat/remote-only") + e.writeFile(other, "remote.txt", "from remote\n") + e.mustGit(other, "add", ".") + e.mustGit(other, "commit", "-m", "remote commit") + e.mustGit(other, "push", "--set-upstream", "origin", "feat/remote-only") + + // Our repo doesn't know about this branch locally + r := e.runGitm("checkout", "feat/remote-only", "--repo", "co-remote") + e.assertExitCode(r, 0) + + branch := e.currentBranch(repo) + if branch != "feat/remote-only" { + // FINDING: gitm claims to check remote branches but may require a fetch first + // or the implementation doesn't handle remote-only branches as documented. + t.Logf("FINDING: checkout of remote-only branch did not work. Current branch: %s", branch) + t.Log("README states: 'Checks branch locally then remote' — but actual behavior differs.") + t.Log("gitm may need an explicit fetch before checking remote branches.") + t.Logf("Output: stdout=%s stderr=%s", r.Stdout, r.Stderr) + } +} + +func TestCheckout_PullsAfterSwitch(t *testing.T) { + e := newTestEnv(t) + repo, origin := e.initRepoWithRemote("co-pulls") + e.runGitm("repo", "add", repo, "--alias", "co-pulls") + + // Create a branch, push it, then push more commits from another clone + e.mustGit(repo, "checkout", "-b", "feat/pull-test") + e.writeFile(repo, "first.txt", "first\n") + e.mustGit(repo, "add", ".") + e.mustGit(repo, "commit", "-m", "first") + e.mustGit(repo, "push", "--set-upstream", "origin", "feat/pull-test") + e.mustGit(repo, "checkout", "main") + + // Push more commits from another clone + other := e.cloneRepo(origin, "co-pulls-other") + e.mustGit(other, "checkout", "feat/pull-test") + e.writeFile(other, "second.txt", "second\n") + e.mustGit(other, "add", ".") + e.mustGit(other, "commit", "-m", "second from other") + e.mustGit(other, "push") + + // Checkout should switch AND pull + r := e.runGitm("checkout", "feat/pull-test", "--repo", "co-pulls") + e.assertExitCode(r, 0) + + // Should have the latest file from the other clone + if !e.fileExists(repo + "/second.txt") { + t.Error("checkout did not pull latest — second.txt missing") + } +} diff --git a/internal/e2e/help_test.go b/internal/e2e/help_test.go new file mode 100644 index 0000000..de9e315 --- /dev/null +++ b/internal/e2e/help_test.go @@ -0,0 +1,95 @@ +package e2e + +import ( + "strings" + "testing" +) + +// ========================================================================== +// Phase 12: Help & Version (gitm --help, --version, unknown commands) +// ========================================================================== + +func TestVersion(t *testing.T) { + e := newTestEnv(t) + + r := e.runGitm("--version") + e.assertExitCode(r, 0) + // Should contain "gitm version" + if !strings.Contains(r.Stdout, "gitm version") { + t.Errorf("expected 'gitm version' in output, got: %s", r.Stdout) + } + // Our test build uses "e2e-test" as version + e.assertStdoutContains(r, "e2e-test") +} + +func TestHelp_RootCommand(t *testing.T) { + e := newTestEnv(t) + + r := e.runGitm("--help") + e.assertExitCode(r, 0) + + // Should list all main commands + expectedCommands := []string{ + "branch", "checkout", "commit", "discard", + "repo", "reset", "stash", "status", + "track", "untrack", "update", "upgrade", + } + for _, cmd := range expectedCommands { + if !strings.Contains(r.Stdout, cmd) { + t.Errorf("help output missing command: %s", cmd) + } + } +} + +func TestHelp_SubCommands(t *testing.T) { + e := newTestEnv(t) + + commands := []string{ + "repo", "branch", "checkout", "commit", + "discard", "stash", "status", "track", + "untrack", "update", "reset", "upgrade", + } + + for _, cmd := range commands { + t.Run(cmd, func(t *testing.T) { + r := e.runGitm(cmd, "--help") + e.assertExitCode(r, 0) + // Each help should contain "Usage:" section + if !strings.Contains(r.Stdout, "Usage:") { + t.Errorf("gitm %s --help missing 'Usage:' section", cmd) + } + }) + } +} + +func TestUnknownCommand(t *testing.T) { + e := newTestEnv(t) + + r := e.runGitm("foobar-unknown") + // Should exit non-zero with error about unknown command + if r.ExitCode == 0 { + t.Error("expected non-zero exit code for unknown command") + } + e.assertContains(r, "unknown") +} + +// ========================================================================== +// Phase 13: Upgrade (gitm upgrade) +// Only test that it runs without crashing. Don't actually upgrade. +// ========================================================================== + +func TestUpgrade_SkipsDBInit(t *testing.T) { + e := newTestEnv(t) + + // Upgrade should work even without any DB initialization + // (it's in the skip list for PersistentPreRunE) + r := e.runGitm("upgrade") + // It will try to check GitHub releases — may fail due to network + // but should NOT fail due to DB issues + combined := r.Stdout + r.Stderr + if strings.Contains(combined, "database") || strings.Contains(combined, "gitm.db") { + t.Error("upgrade should not require database initialization") + } + t.Logf("Upgrade output: exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) +} diff --git a/internal/e2e/helpers_test.go b/internal/e2e/helpers_test.go new file mode 100644 index 0000000..2ec602f --- /dev/null +++ b/internal/e2e/helpers_test.go @@ -0,0 +1,302 @@ +package e2e + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// gitmBinary holds the path to the built gitm binary. +// Set once in TestMain. +var gitmBinary string + +func TestMain(m *testing.M) { + // Build the binary once for all e2e tests. + binary, err := buildGitm() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to build gitm: %v\n", err) + os.Exit(1) + } + gitmBinary = binary + + os.Exit(m.Run()) +} + +// buildGitm compiles the gitm binary into a temp directory and returns its path. +func buildGitm() (string, error) { + dir, err := os.MkdirTemp("", "gitm-e2e-bin-*") + if err != nil { + return "", err + } + + binary := filepath.Join(dir, "gitm") + if runtime.GOOS == "windows" { + binary += ".exe" + } + + // Find the project root (two levels up from internal/e2e) + _, thisFile, _, _ := runtime.Caller(0) + projectRoot := filepath.Join(filepath.Dir(thisFile), "..", "..") + + cmd := exec.Command("go", "build", + "-ldflags", "-X main.version=e2e-test", + "-o", binary, + "./cmd/gitm", + ) + cmd.Dir = projectRoot + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("go build: %v\n%s", err, out) + } + return binary, nil +} + +// -------------------------------------------------------------------------- +// Test environment helpers +// -------------------------------------------------------------------------- + +// testEnv represents an isolated test environment with its own HOME dir. +type testEnv struct { + t *testing.T + homeDir string + dataDir string // ~/.gitm/ equivalent +} + +// newTestEnv creates an isolated environment for a single test. +// It sets up a fresh HOME directory so gitm gets its own database. +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + home := t.TempDir() + dataDir := filepath.Join(home, ".gitm") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatalf("MkdirAll %s: %v", dataDir, err) + } + return &testEnv{ + t: t, + homeDir: home, + dataDir: dataDir, + } +} + +// -------------------------------------------------------------------------- +// Running gitm +// -------------------------------------------------------------------------- + +// result holds the output of a gitm invocation. +type result struct { + Stdout string + Stderr string + ExitCode int +} + +// runGitm executes the gitm binary with the given arguments in this test environment. +func (e *testEnv) runGitm(args ...string) result { + e.t.Helper() + return e.runGitmInDir("", args...) +} + +// runGitmInDir executes gitm with a specific working directory. +func (e *testEnv) runGitmInDir(dir string, args ...string) result { + e.t.Helper() + + cmd := exec.Command(gitmBinary, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "HOME="+e.homeDir, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + e.t.Fatalf("failed to run gitm %v: %v", args, err) + } + } + + return result{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + } +} + +// -------------------------------------------------------------------------- +// Git repo setup helpers +// -------------------------------------------------------------------------- + +// initRepo creates a new git repository in a temp directory with an initial commit. +// Returns the repo path. +func (e *testEnv) initRepo(name string) string { + e.t.Helper() + dir := filepath.Join(e.t.TempDir(), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + e.t.Fatalf("MkdirAll: %v", err) + } + e.mustGit(dir, "init", "-b", "main") + e.mustGit(dir, "config", "user.email", "test@e2e.dev") + e.mustGit(dir, "config", "user.name", "E2E Test") + e.mustGit(dir, "config", "commit.gpgsign", "false") + e.writeFile(dir, "README.md", "# "+name+"\n") + e.mustGit(dir, "add", ".") + e.mustGit(dir, "commit", "-m", "initial commit") + return dir +} + +// initRepoWithRemote creates a repo with a bare remote "origin" and pushes the initial commit. +// Returns (repoDir, bareOriginDir). +func (e *testEnv) initRepoWithRemote(name string) (string, string) { + e.t.Helper() + // Create bare remote + origin := filepath.Join(e.t.TempDir(), name+"-origin.git") + if err := os.MkdirAll(origin, 0o755); err != nil { + e.t.Fatalf("MkdirAll: %v", err) + } + e.mustGit(origin, "init", "--bare", "--initial-branch=main") + + // Create working repo + repo := e.initRepo(name) + e.mustGit(repo, "remote", "add", "origin", origin) + e.mustGit(repo, "push", "--set-upstream", "origin", "main") + + return repo, origin +} + +// cloneRepo clones from an origin into a new temp directory. +func (e *testEnv) cloneRepo(origin, name string) string { + e.t.Helper() + dir := filepath.Join(e.t.TempDir(), name) + cmd := exec.Command("git", "clone", origin, dir) + out, err := cmd.CombinedOutput() + if err != nil { + e.t.Fatalf("git clone: %v\n%s", err, out) + } + e.mustGit(dir, "config", "user.email", "test@e2e.dev") + e.mustGit(dir, "config", "user.name", "E2E Test") + e.mustGit(dir, "config", "commit.gpgsign", "false") + return dir +} + +// mustGit runs a git command in the given directory, failing the test on error. +func (e *testEnv) mustGit(dir string, args ...string) string { + e.t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ) + out, err := cmd.CombinedOutput() + if err != nil { + e.t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) + } + return strings.TrimRight(string(out), "\r\n") +} + +// writeFile creates a file with the given content. +func (e *testEnv) writeFile(dir, name, content string) { + e.t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + e.t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + e.t.Fatalf("WriteFile %s: %v", name, err) + } +} + +// fileExists checks if a file exists at the given path. +func (e *testEnv) fileExists(path string) bool { + e.t.Helper() + _, err := os.Stat(path) + return err == nil +} + +// currentBranch returns the current branch of a git repo. +func (e *testEnv) currentBranch(dir string) string { + e.t.Helper() + return e.mustGit(dir, "rev-parse", "--abbrev-ref", "HEAD") +} + +// gitLog returns the last N commit messages (one-line format). +func (e *testEnv) gitLog(dir string, n int) string { + e.t.Helper() + return e.mustGit(dir, "log", fmt.Sprintf("-%d", n), "--oneline") +} + +// isDirty returns whether the repo has uncommitted tracked changes. +func (e *testEnv) isDirty(dir string) bool { + e.t.Helper() + out := e.mustGit(dir, "status", "--porcelain") + // Filter out untracked files (lines starting with ??) + for _, line := range strings.Split(out, "\n") { + if line == "" { + continue + } + if !strings.HasPrefix(line, "??") { + return true + } + } + return false +} + +// branchExists checks if a branch exists locally in the repo. +func (e *testEnv) branchExists(dir, branch string) bool { + e.t.Helper() + cmd := exec.Command("git", "rev-parse", "--verify", "refs/heads/"+branch) + cmd.Dir = dir + return cmd.Run() == nil +} + +// -------------------------------------------------------------------------- +// Assertion helpers +// -------------------------------------------------------------------------- + +// assertExitCode checks the exit code of a result. +func (e *testEnv) assertExitCode(r result, expected int) { + e.t.Helper() + if r.ExitCode != expected { + e.t.Errorf("expected exit code %d, got %d\nstdout: %s\nstderr: %s", + expected, r.ExitCode, r.Stdout, r.Stderr) + } +} + +// assertContains checks that stdout or stderr contains a substring. +func (e *testEnv) assertContains(r result, substr string) { + e.t.Helper() + combined := r.Stdout + r.Stderr + if !strings.Contains(combined, substr) { + e.t.Errorf("expected output to contain %q\nstdout: %s\nstderr: %s", + substr, r.Stdout, r.Stderr) + } +} + +// assertNotContains checks that output does NOT contain a substring. +func (e *testEnv) assertNotContains(r result, substr string) { + e.t.Helper() + combined := r.Stdout + r.Stderr + if strings.Contains(combined, substr) { + e.t.Errorf("expected output NOT to contain %q\nstdout: %s\nstderr: %s", + substr, r.Stdout, r.Stderr) + } +} + +// assertStdoutContains checks that stdout specifically contains a substring. +func (e *testEnv) assertStdoutContains(r result, substr string) { + e.t.Helper() + if !strings.Contains(r.Stdout, substr) { + e.t.Errorf("expected stdout to contain %q\ngot: %s", substr, r.Stdout) + } +} diff --git a/internal/e2e/interactive_test.go b/internal/e2e/interactive_test.go new file mode 100644 index 0000000..24fd370 --- /dev/null +++ b/internal/e2e/interactive_test.go @@ -0,0 +1,112 @@ +package e2e + +import ( + "testing" +) + +// ========================================================================== +// Phase 8: Commit (gitm commit) +// Note: Commit is heavily TUI-dependent (file selection + message input). +// We can only test edge cases that exit before TUI interaction. +// ========================================================================== + +func TestCommit_NoDirtyRepos(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("commit-clean") + e.runGitm("repo", "add", repo, "--alias", "commit-clean") + + r := e.runGitm("commit", "--repo", "commit-clean") + e.assertExitCode(r, 0) + // Should indicate no dirty repos + e.assertContains(r, "No") +} + +func TestCommit_ProtectedDefaultBranch(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("commit-protected") + e.runGitm("repo", "add", repo, "--alias", "commit-protected") + + // Stay on main (default branch) and make it dirty + e.writeFile(repo, "dirty.txt", "dirty\n") + + // With --repo, this bypasses repo selection but file selection is TUI + // The protection should prevent proceeding + r := e.runGitm("commit", "--repo", "commit-protected") + t.Logf("Commit on protected branch: exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) + // Should mention "protected" or "default branch" + combined := r.Stdout + r.Stderr + if !containsAny(combined, "protected", "default", "No") { + t.Log("Note: commit on default branch did not explicitly mention protection") + } +} + +// ========================================================================== +// Phase 9: Discard (gitm discard) +// Note: Discard is TUI-dependent for file selection. +// We can only test the "all clean" edge case automatically. +// ========================================================================== + +func TestDiscard_AllReposClean(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("discard-clean") + e.runGitm("repo", "add", repo, "--alias", "discard-clean") + + r := e.runGitm("discard", "--repo", "discard-clean") + e.assertExitCode(r, 0) + // Should indicate all clean + e.assertContains(r, "clean") +} + +// ========================================================================== +// Phase 10: Stash (gitm stash) +// Note: stash has no --repo flag, fully TUI-dependent. +// We test stash list (non-interactive) and edge cases. +// ========================================================================== + +func TestStashList_NoStashes(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("stash-empty") + e.runGitm("repo", "add", repo, "--alias", "stash-empty") + + r := e.runGitm("stash", "list") + e.assertExitCode(r, 0) + // Should indicate no stashes or empty table + t.Logf("Stash list (no stashes): exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) +} + +func TestStashList_WithStashes(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("stash-has") + e.runGitm("repo", "add", repo, "--alias", "stash-has") + + // Create a stash manually via git + e.writeFile(repo, "stashme.txt", "stash this\n") + e.mustGit(repo, "add", ".") + e.mustGit(repo, "stash", "push", "-m", "manual stash for test") + + r := e.runGitm("stash", "list") + e.assertExitCode(r, 0) + e.assertStdoutContains(r, "stash-has") +} + +// ========================================================================== +// Phase 11: Reset (gitm reset) +// Note: Reset is TUI-dependent (repo selection). No --repo flag. +// We document expectations but cannot fully automate. +// ========================================================================== + +// TestReset_Behaviour documents what reset does when invoked non-interactively. +// Since there's no --repo flag, we can only observe exit behaviour. +func TestReset_NoReposToReset(t *testing.T) { + e := newTestEnv(t) + // Register a repo with only 1 commit (can't reset further) + repo, _ := e.initRepoWithRemote("reset-one") + e.runGitm("repo", "add", repo, "--alias", "reset-one") + + // Reset needs TUI interaction — this will likely fail in non-terminal + r := e.runGitm("reset") + t.Logf("Reset (non-interactive): exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) +} diff --git a/internal/e2e/repo_test.go b/internal/e2e/repo_test.go new file mode 100644 index 0000000..0f939d8 --- /dev/null +++ b/internal/e2e/repo_test.go @@ -0,0 +1,328 @@ +package e2e + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ========================================================================== +// Phase 1: Repo Management (gitm repo add/list/remove/rename) +// ========================================================================== + +func TestRepoAdd_ValidRepo(t *testing.T) { + e := newTestEnv(t) + repo := e.initRepo("myrepo") + + r := e.runGitm("repo", "add", repo) + e.assertExitCode(r, 0) + e.assertContains(r, "myrepo") + + // Verify it's listed + list := e.runGitm("repo", "list") + e.assertExitCode(list, 0) + e.assertStdoutContains(list, "myrepo") + e.assertStdoutContains(list, repo) +} + +func TestRepoAdd_WithAlias(t *testing.T) { + e := newTestEnv(t) + repo := e.initRepo("original-name") + + r := e.runGitm("repo", "add", repo, "--alias", "custom-alias") + e.assertExitCode(r, 0) + + list := e.runGitm("repo", "list") + e.assertStdoutContains(list, "custom-alias") +} + +func TestRepoAdd_DuplicatePath(t *testing.T) { + e := newTestEnv(t) + repo := e.initRepo("dup") + + // First add should succeed + r1 := e.runGitm("repo", "add", repo) + e.assertExitCode(r1, 0) + + // Second add should fail + r2 := e.runGitm("repo", "add", repo) + if r2.ExitCode == 0 { + // Check if it's a warning instead of error + e.assertContains(r2, "already") + } +} + +func TestRepoAdd_NonGitDirectory(t *testing.T) { + e := newTestEnv(t) + dir := t.TempDir() // Just a directory, not a git repo + + r := e.runGitm("repo", "add", dir) + // Should error — not a git repo + if r.ExitCode == 0 { + t.Errorf("expected non-zero exit code for non-git dir, got 0\nstdout: %s\nstderr: %s", + r.Stdout, r.Stderr) + } +} + +func TestRepoAdd_NonExistentPath(t *testing.T) { + e := newTestEnv(t) + + r := e.runGitm("repo", "add", "/does/not/exist/anywhere") + if r.ExitCode == 0 { + t.Errorf("expected non-zero exit code for non-existent path, got 0") + } +} + +func TestRepoAdd_ConflictingAlias(t *testing.T) { + e := newTestEnv(t) + repo1 := e.initRepo("repo1") + repo2 := e.initRepo("repo2") + + r1 := e.runGitm("repo", "add", repo1, "--alias", "shared-alias") + e.assertExitCode(r1, 0) + + r2 := e.runGitm("repo", "add", repo2, "--alias", "shared-alias") + // FINDING: gitm may allow duplicate aliases (exits 0 even with same alias). + // Document the actual behavior for the report. + if r2.ExitCode == 0 { + // Check if a warning was shown or if both repos are listed + list := e.runGitm("repo", "list") + t.Logf("FINDING: duplicate alias 'shared-alias' accepted (exit 0). List output:\n%s", list.Stdout) + // Count how many times the alias appears + count := strings.Count(list.Stdout, "shared-alias") + if count > 1 { + t.Errorf("FINDING: duplicate alias 'shared-alias' created %d entries — DB UNIQUE constraint not enforced at CLI level", count) + } else if count == 1 { + t.Log("FINDING: second add with same alias silently failed or was deduplicated, only 1 entry") + } + } +} + +func TestRepoAdd_CurrentDirectory(t *testing.T) { + e := newTestEnv(t) + repo := e.initRepo("dot-repo") + + r := e.runGitmInDir(repo, "repo", "add", ".") + e.assertExitCode(r, 0) + + // Verify the absolute path is stored, not "." + list := e.runGitm("repo", "list") + e.assertNotContains(list, " . ") +} + +func TestRepoList_Empty(t *testing.T) { + e := newTestEnv(t) + + r := e.runGitm("repo", "list") + e.assertExitCode(r, 0) + // gitm says "No repositories registered" when empty + e.assertContains(r, "No repositories registered") +} + +func TestRepoList_ShowsAllFields(t *testing.T) { + e := newTestEnv(t) + repo := e.initRepo("fields-test") + e.runGitm("repo", "add", repo, "--alias", "fields-test") + + r := e.runGitm("repo", "list") + e.assertExitCode(r, 0) + e.assertStdoutContains(r, "ALIAS") + e.assertStdoutContains(r, "DEFAULT BRANCH") + e.assertStdoutContains(r, "PATH") + e.assertStdoutContains(r, "fields-test") +} + +func TestRepoRemove_Valid(t *testing.T) { + e := newTestEnv(t) + repo := e.initRepo("to-remove") + e.runGitm("repo", "add", repo, "--alias", "to-remove") + + r := e.runGitm("repo", "remove", "to-remove") + e.assertExitCode(r, 0) + + // Verify gone from list + list := e.runGitm("repo", "list") + e.assertNotContains(list, "to-remove") + + // Verify files still exist on disk + if !e.fileExists(filepath.Join(repo, "README.md")) { + t.Error("repo files were deleted from disk — remove should only affect DB") + } +} + +func TestRepoRemove_NonExistent(t *testing.T) { + e := newTestEnv(t) + + r := e.runGitm("repo", "remove", "ghost-alias") + if r.ExitCode == 0 { + t.Error("expected error when removing non-existent alias") + } +} + +func TestRepoRemove_RmAlias(t *testing.T) { + e := newTestEnv(t) + repo := e.initRepo("rm-test") + e.runGitm("repo", "add", repo, "--alias", "rm-test") + + r := e.runGitm("repo", "rm", "rm-test") + e.assertExitCode(r, 0) + + list := e.runGitm("repo", "list") + e.assertNotContains(list, "rm-test") +} + +func TestRepoRename_Valid(t *testing.T) { + e := newTestEnv(t) + repo := e.initRepo("renamed-repo") + e.runGitm("repo", "add", repo, "--alias", "before-rename") + + r := e.runGitm("repo", "rename", "before-rename", "after-rename") + e.assertExitCode(r, 0) + + list := e.runGitm("repo", "list") + e.assertStdoutContains(list, "after-rename") + // Check the alias column changed — the path may still contain the old dir name + // so we check the ALIAS column specifically by looking for the alias field alignment + if strings.Contains(list.Stdout, "before-rename") { + // The alias "before-rename" should no longer appear as an alias + // But it might appear in the PATH column if the directory was named that way. + // Since we named it "renamed-repo", it shouldn't appear at all. + t.Errorf("old alias 'before-rename' still appears in list output") + } +} + +func TestRepoRename_ToExistingAlias(t *testing.T) { + e := newTestEnv(t) + repo1 := e.initRepo("rename-a") + repo2 := e.initRepo("rename-b") + e.runGitm("repo", "add", repo1, "--alias", "alias-a") + e.runGitm("repo", "add", repo2, "--alias", "alias-b") + + r := e.runGitm("repo", "rename", "alias-a", "alias-b") + if r.ExitCode == 0 { + t.Error("expected error when renaming to existing alias") + } +} + +func TestRepoRename_NonExistentSource(t *testing.T) { + e := newTestEnv(t) + + r := e.runGitm("repo", "rename", "ghost", "new") + if r.ExitCode == 0 { + t.Error("expected error when renaming non-existent alias") + } +} + +func TestRepoAdd_AutoDetect(t *testing.T) { + e := newTestEnv(t) + // Create a parent dir with multiple git repos + parent := t.TempDir() + repo1 := filepath.Join(parent, "project-a") + repo2 := filepath.Join(parent, "project-b") + nonGit := filepath.Join(parent, "not-a-repo") + + os.MkdirAll(repo1, 0o755) + os.MkdirAll(repo2, 0o755) + os.MkdirAll(nonGit, 0o755) + + e.mustGit(repo1, "init", "-b", "main") + e.mustGit(repo1, "config", "user.email", "t@t.dev") + e.mustGit(repo1, "config", "user.name", "T") + e.mustGit(repo1, "config", "commit.gpgsign", "false") + e.writeFile(repo1, "f.txt", "a") + e.mustGit(repo1, "add", ".") + e.mustGit(repo1, "commit", "-m", "init") + + e.mustGit(repo2, "init", "-b", "main") + e.mustGit(repo2, "config", "user.email", "t@t.dev") + e.mustGit(repo2, "config", "user.name", "T") + e.mustGit(repo2, "config", "commit.gpgsign", "false") + e.writeFile(repo2, "f.txt", "b") + e.mustGit(repo2, "add", ".") + e.mustGit(repo2, "commit", "-m", "init") + + r := e.runGitm("repo", "add", parent, "--auto-detect") + e.assertExitCode(r, 0) + + list := e.runGitm("repo", "list") + e.assertStdoutContains(list, "project-a") + e.assertStdoutContains(list, "project-b") + e.assertNotContains(list, "not-a-repo") +} + +func TestRepoAdd_AutoDetectWithDepth(t *testing.T) { + e := newTestEnv(t) + // Create nested structure: parent/sub/deep-repo + parent := t.TempDir() + deepRepo := filepath.Join(parent, "sub", "deep-repo") + os.MkdirAll(deepRepo, 0o755) + + e.mustGit(deepRepo, "init", "-b", "main") + e.mustGit(deepRepo, "config", "user.email", "t@t.dev") + e.mustGit(deepRepo, "config", "user.name", "T") + e.mustGit(deepRepo, "config", "commit.gpgsign", "false") + e.writeFile(deepRepo, "f.txt", "x") + e.mustGit(deepRepo, "add", ".") + e.mustGit(deepRepo, "commit", "-m", "init") + + // Depth 1 should NOT find it + r1 := e.runGitm("repo", "add", parent, "--auto-detect", "--depth", "1") + list1 := e.runGitm("repo", "list") + if strings.Contains(list1.Stdout, "deep-repo") { + t.Log("Depth 1 found deep-repo — might be expected depending on implementation") + } + + // Depth 2 should find it + r2 := e.runGitm("repo", "add", parent, "--auto-detect", "--depth", "2") + _ = r1 + _ = r2 + list2 := e.runGitm("repo", "list") + e.assertStdoutContains(list2, "deep-repo") +} + +func TestRepoAdd_AutoDetectWithAlias_Invalid(t *testing.T) { + e := newTestEnv(t) + dir := t.TempDir() + + r := e.runGitm("repo", "add", dir, "--auto-detect", "--alias", "foo") + if r.ExitCode == 0 { + t.Error("expected error when combining --auto-detect with --alias") + } +} + +func TestRepoAdd_MultiplePaths(t *testing.T) { + e := newTestEnv(t) + repo1 := e.initRepo("multi-1") + repo2 := e.initRepo("multi-2") + + r := e.runGitm("repo", "add", repo1, repo2) + e.assertExitCode(r, 0) + + list := e.runGitm("repo", "list") + e.assertStdoutContains(list, "multi-1") + e.assertStdoutContains(list, "multi-2") +} + +func TestRepoAdd_AutoDetectSkipsRegistered(t *testing.T) { + e := newTestEnv(t) + parent := t.TempDir() + repo1 := filepath.Join(parent, "already-there") + os.MkdirAll(repo1, 0o755) + e.mustGit(repo1, "init", "-b", "main") + e.mustGit(repo1, "config", "user.email", "t@t.dev") + e.mustGit(repo1, "config", "user.name", "T") + e.mustGit(repo1, "config", "commit.gpgsign", "false") + e.writeFile(repo1, "f.txt", "a") + e.mustGit(repo1, "add", ".") + e.mustGit(repo1, "commit", "-m", "init") + + // Register it first + e.runGitm("repo", "add", repo1) + + // Auto-detect should show warning but not fail + r := e.runGitm("repo", "add", parent, "--auto-detect") + e.assertExitCode(r, 0) + // Should mention it's already registered (warning) + e.assertContains(r, "already") +} diff --git a/internal/e2e/status_test.go b/internal/e2e/status_test.go new file mode 100644 index 0000000..82ea179 --- /dev/null +++ b/internal/e2e/status_test.go @@ -0,0 +1,166 @@ +package e2e + +import ( + "strings" + "testing" +) + +// ========================================================================== +// Phase 2: Status (gitm status) +// ========================================================================== + +func TestStatus_CleanRepo(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("status-clean") + e.runGitm("repo", "add", repo, "--alias", "status-clean") + + r := e.runGitm("status") + e.assertExitCode(r, 0) + e.assertStdoutContains(r, "status-clean") + e.assertStdoutContains(r, "clean") +} + +func TestStatus_DirtyModified(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("status-dirty") + e.runGitm("repo", "add", repo, "--alias", "status-dirty") + + // Modify a tracked file + e.writeFile(repo, "README.md", "# modified content\n") + + r := e.runGitm("status") + e.assertExitCode(r, 0) + e.assertStdoutContains(r, "status-dirty") + // Should show something indicating dirty (not "clean") + if containsAll(r.Stdout, "status-dirty", "clean") && !containsAny(r.Stdout, "modified", "dirty", "1") { + t.Error("dirty repo shown as clean") + } +} + +func TestStatus_UntrackedOnly(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("status-untracked") + e.runGitm("repo", "add", repo, "--alias", "status-untracked") + + // Add untracked file only + e.writeFile(repo, "newfile.txt", "untracked content\n") + + r := e.runGitm("status") + e.assertExitCode(r, 0) + // FINDING: Per README docs, "untracked files are ignored" for dirty checks. + // However, the status command's DIRTY column may still count untracked files + // as "N modified" in the display. Document the actual behavior. + if strings.Contains(r.Stdout, "clean") { + t.Log("status correctly shows 'clean' for untracked-only repos (matches docs)") + } else if strings.Contains(r.Stdout, "modified") { + t.Log("FINDING: status shows untracked files as 'N modified' in DIRTY column.") + t.Log("This contradicts the README claim that 'untracked files are ignored'.") + t.Log("The 'ignored' behavior may only apply to checkout/update skip logic, not status display.") + t.Logf("Actual output:\n%s", r.Stdout) + } +} + +func TestStatus_AheadOfRemote(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("status-ahead") + e.runGitm("repo", "add", repo, "--alias", "status-ahead") + + // Make a local commit without pushing + e.writeFile(repo, "new.txt", "ahead\n") + e.mustGit(repo, "add", ".") + e.mustGit(repo, "commit", "-m", "unpushed commit") + + r := e.runGitm("status") + e.assertExitCode(r, 0) + e.assertStdoutContains(r, "ahead") +} + +func TestStatus_BehindRemote(t *testing.T) { + e := newTestEnv(t) + repo, origin := e.initRepoWithRemote("status-behind") + e.runGitm("repo", "add", repo, "--alias", "status-behind") + + // Push a commit from another clone to make our repo behind + other := e.cloneRepo(origin, "other-clone") + e.writeFile(other, "from-other.txt", "new\n") + e.mustGit(other, "add", ".") + e.mustGit(other, "commit", "-m", "commit from other") + e.mustGit(other, "push") + + // Fetch so local knows about the new commit + e.mustGit(repo, "fetch") + + r := e.runGitm("status") + e.assertExitCode(r, 0) + e.assertStdoutContains(r, "behind") +} + +func TestStatus_WithFetch(t *testing.T) { + e := newTestEnv(t) + repo, origin := e.initRepoWithRemote("status-fetch") + e.runGitm("repo", "add", repo, "--alias", "status-fetch") + + // Push a commit from another clone + other := e.cloneRepo(origin, "other-fetch") + e.writeFile(other, "remote-new.txt", "new\n") + e.mustGit(other, "add", ".") + e.mustGit(other, "commit", "-m", "remote commit") + e.mustGit(other, "push") + + // Without fetch, local doesn't know — status should not show behind + r1 := e.runGitm("status") + e.assertExitCode(r1, 0) + + // With --fetch, should update and show behind + r2 := e.runGitm("status", "--fetch") + e.assertExitCode(r2, 0) + e.assertStdoutContains(r2, "behind") +} + +func TestStatus_MultipleRepos(t *testing.T) { + e := newTestEnv(t) + repo1, _ := e.initRepoWithRemote("multi-status-1") + repo2, _ := e.initRepoWithRemote("multi-status-2") + e.runGitm("repo", "add", repo1, "--alias", "multi-status-1") + e.runGitm("repo", "add", repo2, "--alias", "multi-status-2") + + r := e.runGitm("status") + e.assertExitCode(r, 0) + e.assertStdoutContains(r, "multi-status-1") + e.assertStdoutContains(r, "multi-status-2") +} + +// -------------------------------------------------------------------------- +// Helpers for this file +// -------------------------------------------------------------------------- + +func containsAll(s string, substrs ...string) bool { + for _, sub := range substrs { + if !contains(s, sub) { + return false + } + } + return true +} + +func containsAny(s string, substrs ...string) bool { + for _, sub := range substrs { + if contains(s, sub) { + return true + } + } + return false +} + +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/e2e/track_test.go b/internal/e2e/track_test.go new file mode 100644 index 0000000..05d6876 --- /dev/null +++ b/internal/e2e/track_test.go @@ -0,0 +1,51 @@ +package e2e + +import ( + "testing" +) + +// ========================================================================== +// Phase 6 & 7: Track and Untrack (gitm track / gitm untrack) +// Note: These commands use TUI for file selection. +// We can only test edge cases (no files to track/untrack) automatically. +// The --repo flag bypasses repo selection but file picker is still TUI-based. +// ========================================================================== + +func TestTrack_NoUntrackedFiles(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("track-none") + e.runGitm("repo", "add", repo, "--alias", "track-none") + + r := e.runGitm("track", "--repo", "track-none") + // Should exit gracefully with a "no untracked" message + e.assertExitCode(r, 0) + e.assertContains(r, "No") +} + +func TestTrack_HasUntrackedFiles(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("track-has") + e.runGitm("repo", "add", repo, "--alias", "track-has") + + // Create untracked files + e.writeFile(repo, "newfile.txt", "new\n") + e.writeFile(repo, "another.txt", "another\n") + + // This will try to open TUI — since we're not in a terminal, it may fail or + // show files and immediately return. Document the behaviour. + r := e.runGitm("track", "--repo", "track-has") + t.Logf("Track with untracked files: exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) +} + +func TestUntrack_NoMatchingFiles(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("untrack-none") + e.runGitm("repo", "add", repo, "--alias", "untrack-none") + + // Use a path filter that matches nothing + r := e.runGitm("untrack", "--repo", "untrack-none", "--path", "*.nonexistent") + // Should exit gracefully + t.Logf("Untrack no match: exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) +} diff --git a/internal/e2e/update_test.go b/internal/e2e/update_test.go new file mode 100644 index 0000000..98f680a --- /dev/null +++ b/internal/e2e/update_test.go @@ -0,0 +1,112 @@ +package e2e + +import ( + "testing" +) + +// ========================================================================== +// Phase 5: Update (gitm update) +// ========================================================================== + +func TestUpdate_AlreadyUpToDate(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("up-current") + e.runGitm("repo", "add", repo, "--alias", "up-current") + + r := e.runGitm("update", "--repo", "up-current") + e.assertExitCode(r, 0) + // Should indicate already up-to-date or show success + e.assertContains(r, "up-current") +} + +func TestUpdate_RemoteAhead(t *testing.T) { + e := newTestEnv(t) + repo, origin := e.initRepoWithRemote("up-behind") + e.runGitm("repo", "add", repo, "--alias", "up-behind") + + // Push from another clone + other := e.cloneRepo(origin, "up-other") + e.writeFile(other, "new-from-remote.txt", "new content\n") + e.mustGit(other, "add", ".") + e.mustGit(other, "commit", "-m", "remote update") + e.mustGit(other, "push") + + r := e.runGitm("update", "--repo", "up-behind") + e.assertExitCode(r, 0) + + // Should have the new file + if !e.fileExists(repo + "/new-from-remote.txt") { + t.Error("update did not pull — new-from-remote.txt missing") + } +} + +func TestUpdate_DirtyRepo_Skips(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("up-dirty") + e.runGitm("repo", "add", repo, "--alias", "up-dirty") + + // Make dirty + e.writeFile(repo, "README.md", "# dirty\n") + + r := e.runGitm("update", "--repo", "up-dirty") + e.assertExitCode(r, 0) + // Should skip/warn about dirty + e.assertContains(r, "up-dirty") +} + +func TestUpdate_NonExistentRepo(t *testing.T) { + e := newTestEnv(t) + + r := e.runGitm("update", "--repo", "ghost-repo") + // Should error — alias doesn't match any registered repo + if r.ExitCode == 0 { + // Even with exit 0, check for error message + combined := r.Stdout + r.Stderr + if !containsAny(combined, "not found", "error", "no match", "unknown") { + t.Error("expected error for non-existent --repo alias, but got clean success") + } + } +} + +func TestUpdate_DoesNotSwitchBranch(t *testing.T) { + e := newTestEnv(t) + repo, _ := e.initRepoWithRemote("up-nosw") + e.runGitm("repo", "add", repo, "--alias", "up-nosw") + + // Switch to a feature branch + e.mustGit(repo, "checkout", "-b", "feat/stay-here") + e.mustGit(repo, "push", "--set-upstream", "origin", "feat/stay-here") + + r := e.runGitm("update", "--repo", "up-nosw") + e.assertExitCode(r, 0) + + // Should still be on feat/stay-here + branch := e.currentBranch(repo) + if branch != "feat/stay-here" { + t.Errorf("update should not switch branches, but now on %s", branch) + } +} + +func TestUpdate_DivergedBranch(t *testing.T) { + e := newTestEnv(t) + repo, origin := e.initRepoWithRemote("up-diverged") + e.runGitm("repo", "add", repo, "--alias", "up-diverged") + + // Push a commit from another clone (remote ahead) + other := e.cloneRepo(origin, "up-div-other") + e.writeFile(other, "remote.txt", "remote\n") + e.mustGit(other, "add", ".") + e.mustGit(other, "commit", "-m", "remote commit") + e.mustGit(other, "push") + + // Make a local commit (local ahead too — diverged) + e.writeFile(repo, "local.txt", "local\n") + e.mustGit(repo, "add", ".") + e.mustGit(repo, "commit", "-m", "local commit") + + r := e.runGitm("update", "--repo", "up-diverged") + // --ff-only should fail on diverged branches + // Either exit != 0, or output mentions failure + t.Logf("Diverged update: exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) +} From bb982ad87e325ca102abc6c5b2191e33ffe58adf Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Fri, 1 May 2026 10:56:50 +0100 Subject: [PATCH 2/4] fix: resolve golangci-lint issues in e2e tests - Check os.MkdirAll return values (errcheck) - Remove unused gitLog and isDirty helper methods (unused) - Use errors.As instead of type assertion on *exec.ExitError (errorlint) - Add missing errors import --- internal/e2e/branch_test.go | 2 +- internal/e2e/helpers_test.go | 28 ++++------------------------ internal/e2e/interactive_test.go | 4 ++-- internal/e2e/repo_test.go | 20 +++++++++++++++----- internal/e2e/track_test.go | 2 +- 5 files changed, 23 insertions(+), 33 deletions(-) diff --git a/internal/e2e/branch_test.go b/internal/e2e/branch_test.go index e943cba..d0a9b48 100644 --- a/internal/e2e/branch_test.go +++ b/internal/e2e/branch_test.go @@ -108,7 +108,7 @@ func TestBranchCreate_DirtyRepo(t *testing.T) { e.writeFile(repo, "README.md", "# dirty\n") r := e.runGitm("branch", "create", "feat/dirty-test", "--repo", "bc-dirty") - // Document actual behaviour: does it skip, stash, or proceed? + // Document actual behavior: does it skip, stash, or proceed? t.Logf("Branch create on dirty repo: exit=%d stdout=%s stderr=%s", r.ExitCode, r.Stdout, r.Stderr) } diff --git a/internal/e2e/helpers_test.go b/internal/e2e/helpers_test.go index 2ec602f..d75ab16 100644 --- a/internal/e2e/helpers_test.go +++ b/internal/e2e/helpers_test.go @@ -2,6 +2,7 @@ package e2e import ( "bytes" + "errors" "fmt" "os" "os/exec" @@ -51,7 +52,7 @@ func buildGitm() (string, error) { cmd.Dir = projectRoot out, err := cmd.CombinedOutput() if err != nil { - return "", fmt.Errorf("go build: %v\n%s", err, out) + return "", fmt.Errorf("go build: %w\n%s", err, out) } return binary, nil } @@ -119,7 +120,8 @@ func (e *testEnv) runGitmInDir(dir string, args ...string) result { err := cmd.Run() exitCode := 0 if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { exitCode = exitErr.ExitCode() } else { e.t.Fatalf("failed to run gitm %v: %v", args, err) @@ -230,28 +232,6 @@ func (e *testEnv) currentBranch(dir string) string { return e.mustGit(dir, "rev-parse", "--abbrev-ref", "HEAD") } -// gitLog returns the last N commit messages (one-line format). -func (e *testEnv) gitLog(dir string, n int) string { - e.t.Helper() - return e.mustGit(dir, "log", fmt.Sprintf("-%d", n), "--oneline") -} - -// isDirty returns whether the repo has uncommitted tracked changes. -func (e *testEnv) isDirty(dir string) bool { - e.t.Helper() - out := e.mustGit(dir, "status", "--porcelain") - // Filter out untracked files (lines starting with ??) - for _, line := range strings.Split(out, "\n") { - if line == "" { - continue - } - if !strings.HasPrefix(line, "??") { - return true - } - } - return false -} - // branchExists checks if a branch exists locally in the repo. func (e *testEnv) branchExists(dir, branch string) bool { e.t.Helper() diff --git a/internal/e2e/interactive_test.go b/internal/e2e/interactive_test.go index 24fd370..dbfc711 100644 --- a/internal/e2e/interactive_test.go +++ b/internal/e2e/interactive_test.go @@ -97,8 +97,8 @@ func TestStashList_WithStashes(t *testing.T) { // We document expectations but cannot fully automate. // ========================================================================== -// TestReset_Behaviour documents what reset does when invoked non-interactively. -// Since there's no --repo flag, we can only observe exit behaviour. +// TestReset_Behavior documents what reset does when invoked non-interactively. +// Since there's no --repo flag, we can only observe exit behavior. func TestReset_NoReposToReset(t *testing.T) { e := newTestEnv(t) // Register a repo with only 1 commit (can't reset further) diff --git a/internal/e2e/repo_test.go b/internal/e2e/repo_test.go index 0f939d8..30ed097 100644 --- a/internal/e2e/repo_test.go +++ b/internal/e2e/repo_test.go @@ -222,9 +222,15 @@ func TestRepoAdd_AutoDetect(t *testing.T) { repo2 := filepath.Join(parent, "project-b") nonGit := filepath.Join(parent, "not-a-repo") - os.MkdirAll(repo1, 0o755) - os.MkdirAll(repo2, 0o755) - os.MkdirAll(nonGit, 0o755) + if err := os.MkdirAll(repo1, 0o755); err != nil { + t.Fatalf("MkdirAll repo1: %v", err) + } + if err := os.MkdirAll(repo2, 0o755); err != nil { + t.Fatalf("MkdirAll repo2: %v", err) + } + if err := os.MkdirAll(nonGit, 0o755); err != nil { + t.Fatalf("MkdirAll nonGit: %v", err) + } e.mustGit(repo1, "init", "-b", "main") e.mustGit(repo1, "config", "user.email", "t@t.dev") @@ -256,7 +262,9 @@ func TestRepoAdd_AutoDetectWithDepth(t *testing.T) { // Create nested structure: parent/sub/deep-repo parent := t.TempDir() deepRepo := filepath.Join(parent, "sub", "deep-repo") - os.MkdirAll(deepRepo, 0o755) + if err := os.MkdirAll(deepRepo, 0o755); err != nil { + t.Fatalf("MkdirAll deepRepo: %v", err) + } e.mustGit(deepRepo, "init", "-b", "main") e.mustGit(deepRepo, "config", "user.email", "t@t.dev") @@ -308,7 +316,9 @@ func TestRepoAdd_AutoDetectSkipsRegistered(t *testing.T) { e := newTestEnv(t) parent := t.TempDir() repo1 := filepath.Join(parent, "already-there") - os.MkdirAll(repo1, 0o755) + if err := os.MkdirAll(repo1, 0o755); err != nil { + t.Fatalf("MkdirAll repo1: %v", err) + } e.mustGit(repo1, "init", "-b", "main") e.mustGit(repo1, "config", "user.email", "t@t.dev") e.mustGit(repo1, "config", "user.name", "T") diff --git a/internal/e2e/track_test.go b/internal/e2e/track_test.go index 05d6876..6b2c9dd 100644 --- a/internal/e2e/track_test.go +++ b/internal/e2e/track_test.go @@ -32,7 +32,7 @@ func TestTrack_HasUntrackedFiles(t *testing.T) { e.writeFile(repo, "another.txt", "another\n") // This will try to open TUI — since we're not in a terminal, it may fail or - // show files and immediately return. Document the behaviour. + // show files and immediately return. Document the behavior. r := e.runGitm("track", "--repo", "track-has") t.Logf("Track with untracked files: exit=%d stdout=%s stderr=%s", r.ExitCode, r.Stdout, r.Stderr) From c124367b140bc43b79976488567d63d739c6deaa Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Fri, 1 May 2026 11:23:07 +0100 Subject: [PATCH 3/4] fix: address PR review comments on e2e test suite - Cross-platform: replace /dev/null with os.DevNull, add Windows HOME env vars (USERPROFILE/HOMEDRIVE/HOMEPATH), clean up TestMain temp dir - Stronger assertions: replace t.Log-only tests with real assertions for remote-only checkout, diverged update, dirty branch create, non-existent branch rename, and stash list empty state - Broader match fixes: 'No' -> specific messages like 'No dirty repositories found', 'No untracked files found' - Skip TUI-dependent tests that cannot run in non-TTY environments (commit protection, reset, track file picker) - Remove custom contains/findSubstring reimplementing strings.Contains - Use filepath.Join instead of string concatenation with '/' - Remove unused dataDir struct field - Use upgrade --help instead of real network call in TestUpgrade --- internal/e2e/branch_test.go | 41 +++++++++++++++++++++----------- internal/e2e/checkout_test.go | 11 ++++----- internal/e2e/help_test.go | 11 ++++----- internal/e2e/helpers_test.go | 28 +++++++++++++++------- internal/e2e/interactive_test.go | 39 +++++++----------------------- internal/e2e/status_test.go | 17 ++----------- internal/e2e/track_test.go | 21 ++++------------ internal/e2e/update_test.go | 12 ++++++---- 8 files changed, 78 insertions(+), 102 deletions(-) diff --git a/internal/e2e/branch_test.go b/internal/e2e/branch_test.go index d0a9b48..e87c478 100644 --- a/internal/e2e/branch_test.go +++ b/internal/e2e/branch_test.go @@ -1,6 +1,7 @@ package e2e import ( + "path/filepath" "testing" ) @@ -59,7 +60,7 @@ func TestBranchCreate_FromSpecificBase(t *testing.T) { e.assertExitCode(r, 0) // Should have the develop.txt file (branched from develop) - if !e.fileExists(repo + "/develop.txt") { + if !e.fileExists(filepath.Join(repo, "develop.txt")) { t.Error("branch was not created from develop — develop.txt missing") } } @@ -88,14 +89,23 @@ func TestBranchCreate_FromNonExistentBase(t *testing.T) { repo, _ := e.initRepoWithRemote("bc-bad-base") e.runGitm("repo", "add", repo, "--alias", "bc-bad-base") + startingBranch := e.currentBranch(repo) r := e.runGitm("branch", "create", "feat/x", "--from", "nonexistent-branch", "--repo", "bc-bad-base") - // Should error or show warning about base branch not found - if r.ExitCode == 0 { - // Even if exit 0, output should mention failure - combined := r.Stdout + r.Stderr - if !containsAny(combined, "not found", "error", "failed", "does not exist") { - t.Log("WARNING: creating from non-existent base succeeded silently") - } + + combined := r.Stdout + r.Stderr + // Accept either an explicit command failure or a successful exit that clearly reports + // the invalid base branch. Silent success is incorrect. + if r.ExitCode == 0 && !containsAny(combined, "not found", "error", "failed", "does not exist") { + t.Fatalf("expected branch create from non-existent base to fail or report the missing base branch; exit=%d stdout=%q stderr=%q", + r.ExitCode, r.Stdout, r.Stderr) + } + + if e.branchExists(repo, "feat/x") { + t.Fatal("branch feat/x should not be created when the base branch does not exist") + } + + if branch := e.currentBranch(repo); branch != startingBranch { + t.Fatalf("expected to remain on %s after failing to create from a non-existent base, got %s", startingBranch, branch) } } @@ -108,9 +118,9 @@ func TestBranchCreate_DirtyRepo(t *testing.T) { e.writeFile(repo, "README.md", "# dirty\n") r := e.runGitm("branch", "create", "feat/dirty-test", "--repo", "bc-dirty") - // Document actual behavior: does it skip, stash, or proceed? - t.Logf("Branch create on dirty repo: exit=%d stdout=%s stderr=%s", - r.ExitCode, r.Stdout, r.Stderr) + // Branch create on dirty repos should skip with a warning about uncommitted changes + e.assertExitCode(r, 0) + e.assertContains(r, "uncommitted changes") } func TestBranchRename_WithRepo(t *testing.T) { @@ -196,7 +206,10 @@ func TestBranchRename_NonExistentBranch(t *testing.T) { e.runGitm("repo", "add", repo, "--alias", "br-ghost") r := e.runGitm("branch", "rename", "nonexistent-branch", "new", "--repo", "br-ghost") - // Should skip or error — branch doesn't exist - t.Logf("Rename non-existent: exit=%d stdout=%s stderr=%s", - r.ExitCode, r.Stdout, r.Stderr) + // Renaming a branch that does not exist should fail + if r.ExitCode == 0 { + t.Fatalf("expected non-zero exit code when renaming a non-existent branch; stdout=%s stderr=%s", + r.Stdout, r.Stderr) + } + e.assertContains(r, "no registered repositories have a branch named") } diff --git a/internal/e2e/checkout_test.go b/internal/e2e/checkout_test.go index c16e763..c765987 100644 --- a/internal/e2e/checkout_test.go +++ b/internal/e2e/checkout_test.go @@ -1,6 +1,7 @@ package e2e import ( + "path/filepath" "testing" ) @@ -144,12 +145,8 @@ func TestCheckout_RemoteOnlyBranch(t *testing.T) { branch := e.currentBranch(repo) if branch != "feat/remote-only" { - // FINDING: gitm claims to check remote branches but may require a fetch first - // or the implementation doesn't handle remote-only branches as documented. - t.Logf("FINDING: checkout of remote-only branch did not work. Current branch: %s", branch) - t.Log("README states: 'Checks branch locally then remote' — but actual behavior differs.") - t.Log("gitm may need an explicit fetch before checking remote branches.") - t.Logf("Output: stdout=%s stderr=%s", r.Stdout, r.Stderr) + t.Fatalf("expected checkout of remote-only branch to switch to %q, got %q\nstdout: %s\nstderr: %s", + "feat/remote-only", branch, r.Stdout, r.Stderr) } } @@ -179,7 +176,7 @@ func TestCheckout_PullsAfterSwitch(t *testing.T) { e.assertExitCode(r, 0) // Should have the latest file from the other clone - if !e.fileExists(repo + "/second.txt") { + if !e.fileExists(filepath.Join(repo, "second.txt")) { t.Error("checkout did not pull latest — second.txt missing") } } diff --git a/internal/e2e/help_test.go b/internal/e2e/help_test.go index de9e315..4a6cdf7 100644 --- a/internal/e2e/help_test.go +++ b/internal/e2e/help_test.go @@ -82,14 +82,13 @@ func TestUpgrade_SkipsDBInit(t *testing.T) { e := newTestEnv(t) // Upgrade should work even without any DB initialization - // (it's in the skip list for PersistentPreRunE) - r := e.runGitm("upgrade") - // It will try to check GitHub releases — may fail due to network - // but should NOT fail due to DB issues + // (it's in the skip list for PersistentPreRunE). + // Use --help to validate without making network requests. + r := e.runGitm("upgrade", "--help") + e.assertExitCode(r, 0) + combined := r.Stdout + r.Stderr if strings.Contains(combined, "database") || strings.Contains(combined, "gitm.db") { t.Error("upgrade should not require database initialization") } - t.Logf("Upgrade output: exit=%d stdout=%s stderr=%s", - r.ExitCode, r.Stdout, r.Stderr) } diff --git a/internal/e2e/helpers_test.go b/internal/e2e/helpers_test.go index d75ab16..f35891b 100644 --- a/internal/e2e/helpers_test.go +++ b/internal/e2e/helpers_test.go @@ -25,7 +25,10 @@ func TestMain(m *testing.M) { } gitmBinary = binary - os.Exit(m.Run()) + code := m.Run() + // Clean up the temp directory that holds the built binary. + os.RemoveAll(filepath.Dir(binary)) + os.Exit(code) } // buildGitm compiles the gitm binary into a temp directory and returns its path. @@ -65,7 +68,6 @@ func buildGitm() (string, error) { type testEnv struct { t *testing.T homeDir string - dataDir string // ~/.gitm/ equivalent } // newTestEnv creates an isolated environment for a single test. @@ -80,7 +82,6 @@ func newTestEnv(t *testing.T) *testEnv { return &testEnv{ t: t, homeDir: home, - dataDir: dataDir, } } @@ -107,11 +108,22 @@ func (e *testEnv) runGitmInDir(dir string, args ...string) result { cmd := exec.Command(gitmBinary, args...) cmd.Dir = dir - cmd.Env = append(os.Environ(), + + env := append(os.Environ(), "HOME="+e.homeDir, - "GIT_CONFIG_GLOBAL=/dev/null", - "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_CONFIG_GLOBAL="+os.DevNull, + "GIT_CONFIG_SYSTEM="+os.DevNull, ) + if runtime.GOOS == "windows" { + volume := filepath.VolumeName(e.homeDir) + homePath := strings.TrimPrefix(e.homeDir, volume) + env = append(env, + "USERPROFILE="+e.homeDir, + "HOMEDRIVE="+volume, + "HOMEPATH="+homePath, + ) + } + cmd.Env = env var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -197,8 +209,8 @@ func (e *testEnv) mustGit(dir string, args ...string) string { cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), - "GIT_CONFIG_GLOBAL=/dev/null", - "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_CONFIG_GLOBAL="+os.DevNull, + "GIT_CONFIG_SYSTEM="+os.DevNull, ) out, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/e2e/interactive_test.go b/internal/e2e/interactive_test.go index dbfc711..852051b 100644 --- a/internal/e2e/interactive_test.go +++ b/internal/e2e/interactive_test.go @@ -18,27 +18,13 @@ func TestCommit_NoDirtyRepos(t *testing.T) { r := e.runGitm("commit", "--repo", "commit-clean") e.assertExitCode(r, 0) // Should indicate no dirty repos - e.assertContains(r, "No") + e.assertContains(r, "No dirty repositories found") } func TestCommit_ProtectedDefaultBranch(t *testing.T) { - e := newTestEnv(t) - repo, _ := e.initRepoWithRemote("commit-protected") - e.runGitm("repo", "add", repo, "--alias", "commit-protected") - - // Stay on main (default branch) and make it dirty - e.writeFile(repo, "dirty.txt", "dirty\n") - - // With --repo, this bypasses repo selection but file selection is TUI - // The protection should prevent proceeding - r := e.runGitm("commit", "--repo", "commit-protected") - t.Logf("Commit on protected branch: exit=%d stdout=%s stderr=%s", - r.ExitCode, r.Stdout, r.Stderr) - // Should mention "protected" or "default branch" - combined := r.Stdout + r.Stderr - if !containsAny(combined, "protected", "default", "No") { - t.Log("Note: commit on default branch did not explicitly mention protection") - } + // Commit on a protected default branch requires TTY interaction for confirmation; + // behavior is non-deterministic in non-TTY environments. + t.Skip("commit protection behavior is non-deterministic in non-TTY environments") } // ========================================================================== @@ -71,9 +57,8 @@ func TestStashList_NoStashes(t *testing.T) { r := e.runGitm("stash", "list") e.assertExitCode(r, 0) - // Should indicate no stashes or empty table - t.Logf("Stash list (no stashes): exit=%d stdout=%s stderr=%s", - r.ExitCode, r.Stdout, r.Stderr) + // Should indicate no stashes + e.assertContains(r, "No repositories have stash entries") } func TestStashList_WithStashes(t *testing.T) { @@ -98,15 +83,7 @@ func TestStashList_WithStashes(t *testing.T) { // ========================================================================== // TestReset_Behavior documents what reset does when invoked non-interactively. -// Since there's no --repo flag, we can only observe exit behavior. +// Since there's no --repo flag and reset requires TTY for repo selection, we skip. func TestReset_NoReposToReset(t *testing.T) { - e := newTestEnv(t) - // Register a repo with only 1 commit (can't reset further) - repo, _ := e.initRepoWithRemote("reset-one") - e.runGitm("repo", "add", repo, "--alias", "reset-one") - - // Reset needs TUI interaction — this will likely fail in non-terminal - r := e.runGitm("reset") - t.Logf("Reset (non-interactive): exit=%d stdout=%s stderr=%s", - r.ExitCode, r.Stdout, r.Stderr) + t.Skip("reset requires TTY for repo selection — cannot test non-interactively") } diff --git a/internal/e2e/status_test.go b/internal/e2e/status_test.go index 82ea179..8176222 100644 --- a/internal/e2e/status_test.go +++ b/internal/e2e/status_test.go @@ -136,7 +136,7 @@ func TestStatus_MultipleRepos(t *testing.T) { func containsAll(s string, substrs ...string) bool { for _, sub := range substrs { - if !contains(s, sub) { + if !strings.Contains(s, sub) { return false } } @@ -145,20 +145,7 @@ func containsAll(s string, substrs ...string) bool { func containsAny(s string, substrs ...string) bool { for _, sub := range substrs { - if contains(s, sub) { - return true - } - } - return false -} - -func contains(s, substr string) bool { - return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) -} - -func findSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { + if strings.Contains(s, sub) { return true } } diff --git a/internal/e2e/track_test.go b/internal/e2e/track_test.go index 6b2c9dd..4ad8688 100644 --- a/internal/e2e/track_test.go +++ b/internal/e2e/track_test.go @@ -19,23 +19,12 @@ func TestTrack_NoUntrackedFiles(t *testing.T) { r := e.runGitm("track", "--repo", "track-none") // Should exit gracefully with a "no untracked" message e.assertExitCode(r, 0) - e.assertContains(r, "No") + e.assertContains(r, "No untracked files found") } func TestTrack_HasUntrackedFiles(t *testing.T) { - e := newTestEnv(t) - repo, _ := e.initRepoWithRemote("track-has") - e.runGitm("repo", "add", repo, "--alias", "track-has") - - // Create untracked files - e.writeFile(repo, "newfile.txt", "new\n") - e.writeFile(repo, "another.txt", "another\n") - - // This will try to open TUI — since we're not in a terminal, it may fail or - // show files and immediately return. Document the behavior. - r := e.runGitm("track", "--repo", "track-has") - t.Logf("Track with untracked files: exit=%d stdout=%s stderr=%s", - r.ExitCode, r.Stdout, r.Stderr) + // Track file picker requires TTY interaction — cannot test non-interactively. + t.Skip("track file picker requires TTY interaction") } func TestUntrack_NoMatchingFiles(t *testing.T) { @@ -46,6 +35,6 @@ func TestUntrack_NoMatchingFiles(t *testing.T) { // Use a path filter that matches nothing r := e.runGitm("untrack", "--repo", "untrack-none", "--path", "*.nonexistent") // Should exit gracefully - t.Logf("Untrack no match: exit=%d stdout=%s stderr=%s", - r.ExitCode, r.Stdout, r.Stderr) + e.assertExitCode(r, 0) + e.assertContains(r, "No") } diff --git a/internal/e2e/update_test.go b/internal/e2e/update_test.go index 98f680a..1566f2e 100644 --- a/internal/e2e/update_test.go +++ b/internal/e2e/update_test.go @@ -1,6 +1,7 @@ package e2e import ( + "path/filepath" "testing" ) @@ -35,7 +36,7 @@ func TestUpdate_RemoteAhead(t *testing.T) { e.assertExitCode(r, 0) // Should have the new file - if !e.fileExists(repo + "/new-from-remote.txt") { + if !e.fileExists(filepath.Join(repo, "new-from-remote.txt")) { t.Error("update did not pull — new-from-remote.txt missing") } } @@ -105,8 +106,9 @@ func TestUpdate_DivergedBranch(t *testing.T) { e.mustGit(repo, "commit", "-m", "local commit") r := e.runGitm("update", "--repo", "up-diverged") - // --ff-only should fail on diverged branches - // Either exit != 0, or output mentions failure - t.Logf("Diverged update: exit=%d stdout=%s stderr=%s", - r.ExitCode, r.Stdout, r.Stderr) + // --ff-only should fail on diverged branches. + if r.ExitCode == 0 { + t.Fatalf("expected diverged branch update to fail, but got exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) + } } From b56a1f21f1e3adc7896b633ebe0b729cc1db3fe3 Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Fri, 1 May 2026 20:26:06 +0100 Subject: [PATCH 4/4] fix: address second round of PR review comments - Use filepath.Join for non-existent path test (cross-platform) - Assert exit codes in AutoDetectWithDepth, remove _ = r1/r2 workaround - Replace generic "1" with "changed" in DirtyModified check - Use specific "No files matching" assertion in untrack test - Assert deterministic non-zero exit for unknown --repo alias - Assert idempotent behavior for duplicate path add (exit 0 + warning) - Assert non-zero exit for conflicting alias (fixed in PR #25) --- internal/e2e/repo_test.go | 31 +++++++++++-------------------- internal/e2e/status_test.go | 2 +- internal/e2e/track_test.go | 4 ++-- internal/e2e/update_test.go | 7 ++----- 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/internal/e2e/repo_test.go b/internal/e2e/repo_test.go index 30ed097..4e0c8cd 100644 --- a/internal/e2e/repo_test.go +++ b/internal/e2e/repo_test.go @@ -45,12 +45,10 @@ func TestRepoAdd_DuplicatePath(t *testing.T) { r1 := e.runGitm("repo", "add", repo) e.assertExitCode(r1, 0) - // Second add should fail + // Second add is idempotent: warns and still succeeds r2 := e.runGitm("repo", "add", repo) - if r2.ExitCode == 0 { - // Check if it's a warning instead of error - e.assertContains(r2, "already") - } + e.assertExitCode(r2, 0) + e.assertContains(r2, "already") } func TestRepoAdd_NonGitDirectory(t *testing.T) { @@ -68,7 +66,8 @@ func TestRepoAdd_NonGitDirectory(t *testing.T) { func TestRepoAdd_NonExistentPath(t *testing.T) { e := newTestEnv(t) - r := e.runGitm("repo", "add", "/does/not/exist/anywhere") + nonExistentPath := filepath.Join(t.TempDir(), "does-not-exist") + r := e.runGitm("repo", "add", nonExistentPath) if r.ExitCode == 0 { t.Errorf("expected non-zero exit code for non-existent path, got 0") } @@ -83,20 +82,12 @@ func TestRepoAdd_ConflictingAlias(t *testing.T) { e.assertExitCode(r1, 0) r2 := e.runGitm("repo", "add", repo2, "--alias", "shared-alias") - // FINDING: gitm may allow duplicate aliases (exits 0 even with same alias). - // Document the actual behavior for the report. + // Alias conflicts should fail with non-zero exit if r2.ExitCode == 0 { - // Check if a warning was shown or if both repos are listed - list := e.runGitm("repo", "list") - t.Logf("FINDING: duplicate alias 'shared-alias' accepted (exit 0). List output:\n%s", list.Stdout) - // Count how many times the alias appears - count := strings.Count(list.Stdout, "shared-alias") - if count > 1 { - t.Errorf("FINDING: duplicate alias 'shared-alias' created %d entries — DB UNIQUE constraint not enforced at CLI level", count) - } else if count == 1 { - t.Log("FINDING: second add with same alias silently failed or was deduplicated, only 1 entry") - } + t.Fatalf("expected non-zero exit code for conflicting alias, got 0\nstdout: %s\nstderr: %s", + r2.Stdout, r2.Stderr) } + e.assertContains(r2, "shared-alias") } func TestRepoAdd_CurrentDirectory(t *testing.T) { @@ -276,6 +267,7 @@ func TestRepoAdd_AutoDetectWithDepth(t *testing.T) { // Depth 1 should NOT find it r1 := e.runGitm("repo", "add", parent, "--auto-detect", "--depth", "1") + e.assertExitCode(r1, 0) list1 := e.runGitm("repo", "list") if strings.Contains(list1.Stdout, "deep-repo") { t.Log("Depth 1 found deep-repo — might be expected depending on implementation") @@ -283,8 +275,7 @@ func TestRepoAdd_AutoDetectWithDepth(t *testing.T) { // Depth 2 should find it r2 := e.runGitm("repo", "add", parent, "--auto-detect", "--depth", "2") - _ = r1 - _ = r2 + e.assertExitCode(r2, 0) list2 := e.runGitm("repo", "list") e.assertStdoutContains(list2, "deep-repo") } diff --git a/internal/e2e/status_test.go b/internal/e2e/status_test.go index 8176222..cbb3104 100644 --- a/internal/e2e/status_test.go +++ b/internal/e2e/status_test.go @@ -32,7 +32,7 @@ func TestStatus_DirtyModified(t *testing.T) { e.assertExitCode(r, 0) e.assertStdoutContains(r, "status-dirty") // Should show something indicating dirty (not "clean") - if containsAll(r.Stdout, "status-dirty", "clean") && !containsAny(r.Stdout, "modified", "dirty", "1") { + if containsAll(r.Stdout, "status-dirty", "clean") && !containsAny(r.Stdout, "modified", "dirty", "changed") { t.Error("dirty repo shown as clean") } } diff --git a/internal/e2e/track_test.go b/internal/e2e/track_test.go index 4ad8688..df49986 100644 --- a/internal/e2e/track_test.go +++ b/internal/e2e/track_test.go @@ -34,7 +34,7 @@ func TestUntrack_NoMatchingFiles(t *testing.T) { // Use a path filter that matches nothing r := e.runGitm("untrack", "--repo", "untrack-none", "--path", "*.nonexistent") - // Should exit gracefully + // Should exit gracefully with a specific "no matching files" message e.assertExitCode(r, 0) - e.assertContains(r, "No") + e.assertContains(r, "No files matching") } diff --git a/internal/e2e/update_test.go b/internal/e2e/update_test.go index 1566f2e..3d88df3 100644 --- a/internal/e2e/update_test.go +++ b/internal/e2e/update_test.go @@ -61,11 +61,8 @@ func TestUpdate_NonExistentRepo(t *testing.T) { r := e.runGitm("update", "--repo", "ghost-repo") // Should error — alias doesn't match any registered repo if r.ExitCode == 0 { - // Even with exit 0, check for error message - combined := r.Stdout + r.Stderr - if !containsAny(combined, "not found", "error", "no match", "unknown") { - t.Error("expected error for non-existent --repo alias, but got clean success") - } + t.Fatalf("expected non-zero exit for non-existent --repo alias, but got exit=%d stdout=%s stderr=%s", + r.ExitCode, r.Stdout, r.Stderr) } }