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`.
464 lines
11 KiB
Go
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))
|
|
}
|
|
}
|