diff --git a/internal/e2e/branch_test.go b/internal/e2e/branch_test.go new file mode 100644 index 0000000..e87c478 --- /dev/null +++ b/internal/e2e/branch_test.go @@ -0,0 +1,215 @@ +package e2e + +import ( + "path/filepath" + "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(filepath.Join(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") + + startingBranch := e.currentBranch(repo) + r := e.runGitm("branch", "create", "feat/x", "--from", "nonexistent-branch", "--repo", "bc-bad-base") + + 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) + } +} + +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") + // 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) { + 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") + // 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 new file mode 100644 index 0000000..c765987 --- /dev/null +++ b/internal/e2e/checkout_test.go @@ -0,0 +1,182 @@ +package e2e + +import ( + "path/filepath" + "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" { + 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) + } +} + +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(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 new file mode 100644 index 0000000..4a6cdf7 --- /dev/null +++ b/internal/e2e/help_test.go @@ -0,0 +1,94 @@ +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). + // 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") + } +} diff --git a/internal/e2e/helpers_test.go b/internal/e2e/helpers_test.go new file mode 100644 index 0000000..f35891b --- /dev/null +++ b/internal/e2e/helpers_test.go @@ -0,0 +1,294 @@ +package e2e + +import ( + "bytes" + "errors" + "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 + + 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. +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: %w\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 +} + +// 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, + } +} + +// -------------------------------------------------------------------------- +// 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 + + env := append(os.Environ(), + "HOME="+e.homeDir, + "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 + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + 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="+os.DevNull, + "GIT_CONFIG_SYSTEM="+os.DevNull, + ) + 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") +} + +// 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..852051b --- /dev/null +++ b/internal/e2e/interactive_test.go @@ -0,0 +1,89 @@ +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 dirty repositories found") +} + +func TestCommit_ProtectedDefaultBranch(t *testing.T) { + // 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") +} + +// ========================================================================== +// 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 + e.assertContains(r, "No repositories have stash entries") +} + +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_Behavior documents what reset does when invoked non-interactively. +// Since there's no --repo flag and reset requires TTY for repo selection, we skip. +func TestReset_NoReposToReset(t *testing.T) { + t.Skip("reset requires TTY for repo selection — cannot test non-interactively") +} diff --git a/internal/e2e/repo_test.go b/internal/e2e/repo_test.go new file mode 100644 index 0000000..4e0c8cd --- /dev/null +++ b/internal/e2e/repo_test.go @@ -0,0 +1,329 @@ +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 is idempotent: warns and still succeeds + r2 := e.runGitm("repo", "add", repo) + e.assertExitCode(r2, 0) + 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) + + 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") + } +} + +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") + // Alias conflicts should fail with non-zero exit + if r2.ExitCode == 0 { + 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) { + 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") + + 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") + 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") + 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") + 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") + 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") + } + + // Depth 2 should find it + r2 := e.runGitm("repo", "add", parent, "--auto-detect", "--depth", "2") + e.assertExitCode(r2, 0) + 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") + 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") + 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..cbb3104 --- /dev/null +++ b/internal/e2e/status_test.go @@ -0,0 +1,153 @@ +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", "changed") { + 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 !strings.Contains(s, sub) { + return false + } + } + return true +} + +func containsAny(s string, substrs ...string) bool { + for _, sub := range substrs { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/internal/e2e/track_test.go b/internal/e2e/track_test.go new file mode 100644 index 0000000..df49986 --- /dev/null +++ b/internal/e2e/track_test.go @@ -0,0 +1,40 @@ +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 untracked files found") +} + +func TestTrack_HasUntrackedFiles(t *testing.T) { + // Track file picker requires TTY interaction — cannot test non-interactively. + t.Skip("track file picker requires TTY interaction") +} + +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 with a specific "no matching files" message + e.assertExitCode(r, 0) + e.assertContains(r, "No files matching") +} diff --git a/internal/e2e/update_test.go b/internal/e2e/update_test.go new file mode 100644 index 0000000..3d88df3 --- /dev/null +++ b/internal/e2e/update_test.go @@ -0,0 +1,111 @@ +package e2e + +import ( + "path/filepath" + "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(filepath.Join(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 { + 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) + } +} + +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. + 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) + } +}