Files
gitlocal/cmd/cmd_test.go
Daniel Tomlinson b5f1495680 Adds initial gitlocal CLI and core functionality
Introduces the `gitlocal` command-line tool for managing nested Git repositories.

Includes the following main commands:
- `convert`: Renames `.git` to `.gitlocal`, allowing a parent repository to ignore the converted child repository. Supports recursive scanning and dry-run options. Tracks converted repositories in a global configuration.
- `revert`: Restores `.gitlocal` to `.git`. Includes an option to revert all tracked repositories.
- `status`: Displays a list of all repositories currently tracked by `gitlocal`, showing their path, conversion time, and original remote/branch.

Establishes internal modules for Git operations, configuration management, and recursive repository scanning.
Adds a comprehensive test suite covering core command logic and utility functions.
Initializes Go module and basic project `.gitignore`.
2026-04-11 14:48:01 +01:00

464 lines
11 KiB
Go

package cmd
import (
"bytes"
"os"
"strings"
"testing"
"git.membo.co.uk/dtomlinson/gitlocal/internal/config"
"git.membo.co.uk/dtomlinson/gitlocal/internal/git"
"git.membo.co.uk/dtomlinson/gitlocal/internal/testutil"
)
// setupTestConfig creates a temporary home directory and config for testing
func setupTestConfig(t *testing.T) (cleanup func()) {
t.Helper()
originalHome := os.Getenv("HOME")
tempHome := t.TempDir()
os.Setenv("HOME", tempHome)
return func() {
os.Setenv("HOME", originalHome)
}
}
func TestConvertSingleRepo(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
repoDir := testutil.CreateTempGitRepoWithRemote(t, "git@github.com:test/repo.git")
// Run convert command
convertCmd.Flags().Set("recursive", "false")
convertCmd.Flags().Set("dry-run", "false")
err := runConvert(convertCmd, []string{repoDir})
if err != nil {
t.Fatalf("convert command failed: %v", err)
}
// Verify .git was renamed to .gitlocal
if git.IsGitRepo(repoDir) {
t.Error("expected .git to be converted")
}
if !git.IsGitLocalRepo(repoDir) {
t.Error("expected .gitlocal to exist")
}
// Verify config was updated
cfg, err := config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(cfg.Repos) != 1 {
t.Fatalf("expected 1 repo in config, got %d", len(cfg.Repos))
}
if cfg.Repos[0].Path != repoDir {
t.Errorf("expected path %s, got %s", repoDir, cfg.Repos[0].Path)
}
if cfg.Repos[0].OriginalRemote != "git@github.com:test/repo.git" {
t.Errorf("expected remote to be saved in config, got %s", cfg.Repos[0].OriginalRemote)
}
}
func TestConvertDryRun(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
repoDir := testutil.CreateTempGitRepo(t)
// Run convert with dry-run
dryRun = true
defer func() { dryRun = false }()
convertCmd.Flags().Set("dry-run", "true")
err := runConvert(convertCmd, []string{repoDir})
if err != nil {
t.Fatalf("convert dry-run failed: %v", err)
}
// Verify nothing was actually converted
if !git.IsGitRepo(repoDir) {
t.Error("expected .git to still exist (dry-run)")
}
if git.IsGitLocalRepo(repoDir) {
t.Error("expected .gitlocal to not exist (dry-run)")
}
// Verify config was not updated
cfg, err := config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(cfg.Repos) != 0 {
t.Errorf("expected config to be empty (dry-run), got %d repos", len(cfg.Repos))
}
}
func TestConvertRecursive(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
rootDir, nestedDirs := testutil.CreateNestedRepoStructure(t)
// Run convert recursively
recursive = true
defer func() { recursive = false }()
convertCmd.Flags().Set("recursive", "true")
err := runConvert(convertCmd, []string{rootDir})
if err != nil {
t.Fatalf("convert recursive failed: %v", err)
}
// Verify all nested repos were converted
for _, nestedDir := range nestedDirs {
if git.IsGitRepo(nestedDir) {
t.Errorf("expected %s to be converted", nestedDir)
}
if !git.IsGitLocalRepo(nestedDir) {
t.Errorf("expected %s to have .gitlocal", nestedDir)
}
}
// Verify config has all repos
cfg, err := config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(cfg.Repos) != len(nestedDirs) {
t.Errorf("expected %d repos in config, got %d", len(nestedDirs), len(cfg.Repos))
}
}
func TestConvertAlreadyConverted(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
repoDir := testutil.CreateTempGitRepo(t)
// First convert
convertCmd.Flags().Set("recursive", "false")
err := runConvert(convertCmd, []string{repoDir})
if err != nil {
t.Fatalf("first convert failed: %v", err)
}
// Try to convert again - should register it
err = runConvert(convertCmd, []string{repoDir})
if err != nil {
t.Errorf("expected no error when re-converting, got: %v", err)
}
// Should still only have 1 entry in config
cfg, err := config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(cfg.Repos) != 1 {
t.Errorf("expected 1 repo in config, got %d", len(cfg.Repos))
}
}
func TestConvertNoGitRepo(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
nonGitDir := t.TempDir()
err := runConvert(convertCmd, []string{nonGitDir})
if err == nil {
t.Error("expected error when converting non-git directory")
}
if !strings.Contains(err.Error(), "no .git directory") {
t.Errorf("expected 'no .git directory' error, got: %v", err)
}
}
func TestRevertSingleRepo(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
repoDir := testutil.CreateTempGitRepo(t)
// Convert first
convertCmd.Flags().Set("recursive", "false")
if err := runConvert(convertCmd, []string{repoDir}); err != nil {
t.Fatalf("convert failed: %v", err)
}
// Now revert
err := runRevert(revertCmd, []string{repoDir})
if err != nil {
t.Fatalf("revert command failed: %v", err)
}
// Verify .gitlocal was renamed back to .git
if !git.IsGitRepo(repoDir) {
t.Error("expected .git to exist after revert")
}
if git.IsGitLocalRepo(repoDir) {
t.Error("expected .gitlocal to be gone after revert")
}
// Verify config was updated
cfg, err := config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(cfg.Repos) != 0 {
t.Errorf("expected config to be empty after revert, got %d repos", len(cfg.Repos))
}
}
func TestRevertAll(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
// Create and convert multiple repos
repo1 := testutil.CreateTempGitRepo(t)
repo2 := testutil.CreateTempGitRepo(t)
repo3 := testutil.CreateTempGitRepo(t)
for _, repo := range []string{repo1, repo2, repo3} {
convertCmd.Flags().Set("recursive", "false")
if err := runConvert(convertCmd, []string{repo}); err != nil {
t.Fatalf("convert failed for %s: %v", repo, err)
}
}
// Verify all were converted
cfg, err := config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(cfg.Repos) != 3 {
t.Fatalf("expected 3 repos in config, got %d", len(cfg.Repos))
}
// Revert all
revertAll = true
defer func() { revertAll = false }()
revertCmd.Flags().Set("all", "true")
err = runRevert(revertCmd, []string{})
if err != nil {
t.Fatalf("revert --all failed: %v", err)
}
// Verify all were reverted
for _, repo := range []string{repo1, repo2, repo3} {
if !git.IsGitRepo(repo) {
t.Errorf("expected %s to have .git after revert --all", repo)
}
if git.IsGitLocalRepo(repo) {
t.Errorf("expected %s to not have .gitlocal after revert --all", repo)
}
}
// Verify config is empty
cfg, err = config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(cfg.Repos) != 0 {
t.Errorf("expected config to be empty after revert --all, got %d repos", len(cfg.Repos))
}
}
func TestRevertNoGitLocal(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
nonGitDir := t.TempDir()
err := runRevert(revertCmd, []string{nonGitDir})
if err == nil {
t.Error("expected error when reverting directory without .gitlocal")
}
if !strings.Contains(err.Error(), "no .gitlocal directory") {
t.Errorf("expected 'no .gitlocal directory' error, got: %v", err)
}
}
func TestStatus(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
// Create and convert a repo
repoDir := testutil.CreateTempGitRepoWithRemote(t, "git@github.com:test/repo.git")
convertCmd.Flags().Set("recursive", "false")
if err := runConvert(convertCmd, []string{repoDir}); err != nil {
t.Fatalf("convert failed: %v", err)
}
// Capture output
var buf bytes.Buffer
originalStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := runStatus(statusCmd, []string{})
w.Close()
os.Stdout = originalStdout
buf.ReadFrom(r)
output := buf.String()
if err != nil {
t.Fatalf("status command failed: %v", err)
}
// Verify output contains repo info
if !strings.Contains(output, repoDir) {
t.Errorf("expected output to contain repo path %s", repoDir)
}
if !strings.Contains(output, "git@github.com:test/repo.git") {
t.Error("expected output to contain remote URL")
}
}
func TestStatusEmpty(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
// Capture output
var buf bytes.Buffer
originalStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := runStatus(statusCmd, []string{})
w.Close()
os.Stdout = originalStdout
buf.ReadFrom(r)
output := buf.String()
if err != nil {
t.Fatalf("status command failed: %v", err)
}
if !strings.Contains(output, "No converted repositories") {
t.Error("expected 'No converted repositories' message")
}
}
func TestConvertCurrentDirectory(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
repoDir := testutil.CreateTempGitRepo(t)
// Change to repo directory
originalWd, _ := os.Getwd()
os.Chdir(repoDir)
defer os.Chdir(originalWd)
// Run convert without arguments (should use current directory)
convertCmd.Flags().Set("recursive", "false")
err := runConvert(convertCmd, []string{})
if err != nil {
t.Fatalf("convert current directory failed: %v", err)
}
// Verify conversion
if !git.IsGitLocalRepo(repoDir) {
t.Error("expected current directory to be converted")
}
}
func TestIntegrationFullWorkflow(t *testing.T) {
cleanup := setupTestConfig(t)
defer cleanup()
// Create a root repo with nested repos
rootDir, nestedDirs := testutil.CreateNestedRepoStructure(t)
// Step 1: Convert all recursively
recursive = true
defer func() { recursive = false }()
convertCmd.Flags().Set("recursive", "true")
if err := runConvert(convertCmd, []string{rootDir}); err != nil {
t.Fatalf("recursive convert failed: %v", err)
}
// Verify all converted
for _, nestedDir := range nestedDirs {
if !git.IsGitLocalRepo(nestedDir) {
t.Errorf("expected %s to be converted", nestedDir)
}
}
// Step 2: Check status
cfg, err := config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(cfg.Repos) != len(nestedDirs) {
t.Errorf("expected %d repos in config, got %d", len(nestedDirs), len(cfg.Repos))
}
// Step 3: Revert one repo
revertAll = false
if err := runRevert(revertCmd, []string{nestedDirs[0]}); err != nil {
t.Fatalf("revert single repo failed: %v", err)
}
// Verify it was reverted
if !git.IsGitRepo(nestedDirs[0]) {
t.Error("expected reverted repo to have .git")
}
// Step 4: Revert all remaining
revertAll = true
revertCmd.Flags().Set("all", "true")
if err := runRevert(revertCmd, []string{}); err != nil {
t.Fatalf("revert all failed: %v", err)
}
// Verify all reverted
for _, nestedDir := range nestedDirs {
if !git.IsGitRepo(nestedDir) {
t.Errorf("expected %s to be reverted", nestedDir)
}
}
// Verify config is empty
cfg, err = config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(cfg.Repos) != 0 {
t.Errorf("expected empty config, got %d repos", len(cfg.Repos))
}
}