diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..881d7cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Built binary +gitlocal + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go new file mode 100644 index 0000000..76cb9c7 --- /dev/null +++ b/cmd/cmd_test.go @@ -0,0 +1,463 @@ +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)) + } +} diff --git a/cmd/convert.go b/cmd/convert.go new file mode 100644 index 0000000..3c92ceb --- /dev/null +++ b/cmd/convert.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "git.membo.co.uk/dtomlinson/gitlocal/internal/config" + "git.membo.co.uk/dtomlinson/gitlocal/internal/git" + "git.membo.co.uk/dtomlinson/gitlocal/internal/scanner" + "github.com/spf13/cobra" +) + +var ( + recursive bool + dryRun bool +) + +var convertCmd = &cobra.Command{ + Use: "convert [path]", + Short: "Convert .git to .gitlocal", + Long: `Convert renames .git to .gitlocal in the specified directory. +If no path is provided, uses the current directory. + +Use --recursive to scan and convert all nested git repositories.`, + Args: cobra.MaximumNArgs(1), + RunE: runConvert, +} + +func init() { + convertCmd.Flags().BoolVarP(&recursive, "recursive", "r", false, "Recursively convert all nested repos") + convertCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be converted without making changes") +} + +func runConvert(cmd *cobra.Command, args []string) error { + // Determine target path + targetPath := "." + if len(args) > 0 { + targetPath = args[0] + } + + // Get absolute path + absPath, err := filepath.Abs(targetPath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Load config + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if recursive { + return convertRecursive(absPath, cfg) + } + + return convertSingle(absPath, cfg) +} + +func convertSingle(path string, cfg *config.Config) error { + // Check if already converted + if git.IsGitLocalRepo(path) { + // Check if already in config + if existingRepo := cfg.FindRepo(path); existingRepo != nil { + fmt.Printf("⊘ Already converted and tracked: %s\n", path) + return nil + } + + // Add to config + gitLocalPath := filepath.Join(path, git.GitLocalDir) + remoteURL := git.GetRemoteURL(gitLocalPath) + branch := git.GetCurrentBranch(gitLocalPath) + + cfg.AddRepo(config.Repo{ + Path: path, + ConvertedAt: time.Now(), + OriginalRemote: remoteURL, + OriginalBranch: branch, + }) + + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("✓ Registered already-converted repo: %s\n", path) + if remoteURL != "" { + fmt.Printf(" Remote: %s\n", remoteURL) + } + if branch != "" { + fmt.Printf(" Branch: %s\n", branch) + } + return nil + } + + if !git.IsGitRepo(path) { + return fmt.Errorf("no .git directory found at %s", path) + } + + if dryRun { + fmt.Printf("[DRY RUN] Would convert: %s\n", path) + return nil + } + + // Get git info before converting + gitPath := filepath.Join(path, git.GitDir) + remoteURL := git.GetRemoteURL(gitPath) + branch := git.GetCurrentBranch(gitPath) + + // Convert + if err := git.Convert(path); err != nil { + return err + } + + // Update config + cfg.AddRepo(config.Repo{ + Path: path, + ConvertedAt: time.Now(), + OriginalRemote: remoteURL, + OriginalBranch: branch, + }) + + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("✓ Converted: %s\n", path) + if remoteURL != "" { + fmt.Printf(" Remote: %s\n", remoteURL) + } + if branch != "" { + fmt.Printf(" Branch: %s\n", branch) + } + + return nil +} + +func convertRecursive(rootPath string, cfg *config.Config) error { + repos, err := scanner.FindNestedGitRepos(rootPath) + if err != nil { + return fmt.Errorf("failed to scan for nested repos: %w", err) + } + + if len(repos) == 0 { + fmt.Println("No nested git repositories found") + return nil + } + + fmt.Printf("Found %d nested git repositories\n\n", len(repos)) + + converted := 0 + registered := 0 + for _, repoPath := range repos { + if git.IsGitLocalRepo(repoPath) { + // Check if already in config + if existingRepo := cfg.FindRepo(repoPath); existingRepo != nil { + fmt.Printf("⊘ Skipped (already converted and tracked): %s\n", repoPath) + continue + } + + // Register already-converted repo + gitLocalPath := filepath.Join(repoPath, git.GitLocalDir) + remoteURL := git.GetRemoteURL(gitLocalPath) + branch := git.GetCurrentBranch(gitLocalPath) + + cfg.AddRepo(config.Repo{ + Path: repoPath, + ConvertedAt: time.Now(), + OriginalRemote: remoteURL, + OriginalBranch: branch, + }) + + fmt.Printf("✓ Registered already-converted: %s\n", repoPath) + registered++ + continue + } + + if dryRun { + fmt.Printf("[DRY RUN] Would convert: %s\n", repoPath) + continue + } + + // Get git info before converting + gitPath := filepath.Join(repoPath, git.GitDir) + remoteURL := git.GetRemoteURL(gitPath) + branch := git.GetCurrentBranch(gitPath) + + // Convert + if err := git.Convert(repoPath); err != nil { + fmt.Fprintf(os.Stderr, "✗ Failed to convert %s: %v\n", repoPath, err) + continue + } + + // Update config + cfg.AddRepo(config.Repo{ + Path: repoPath, + ConvertedAt: time.Now(), + OriginalRemote: remoteURL, + OriginalBranch: branch, + }) + + fmt.Printf("✓ Converted: %s\n", repoPath) + converted++ + } + + if !dryRun && (converted > 0 || registered > 0) { + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + fmt.Printf("\nSuccessfully converted %d repositories", converted) + if registered > 0 { + fmt.Printf(", registered %d already-converted", registered) + } + fmt.Println() + } + + return nil +} diff --git a/cmd/revert.go b/cmd/revert.go new file mode 100644 index 0000000..ef5c454 --- /dev/null +++ b/cmd/revert.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "git.membo.co.uk/dtomlinson/gitlocal/internal/config" + "git.membo.co.uk/dtomlinson/gitlocal/internal/git" + "github.com/spf13/cobra" +) + +var revertAll bool + +var revertCmd = &cobra.Command{ + Use: "revert [path]", + Short: "Revert .gitlocal back to .git", + Long: `Revert renames .gitlocal back to .git in the specified directory. +If no path is provided, uses the current directory. + +Use --all to revert all tracked repositories from the config.`, + Args: cobra.MaximumNArgs(1), + RunE: runRevert, +} + +func init() { + revertCmd.Flags().BoolVarP(&revertAll, "all", "a", false, "Revert all tracked repositories") +} + +func runRevert(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if revertAll { + return revertAllRepos(cfg) + } + + // Determine target path + targetPath := "." + if len(args) > 0 { + targetPath = args[0] + } + + // Get absolute path + absPath, err := filepath.Abs(targetPath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + return revertSingle(absPath, cfg) +} + +func revertSingle(path string, cfg *config.Config) error { + if !git.IsGitLocalRepo(path) { + return fmt.Errorf("no .gitlocal directory found at %s", path) + } + + if git.IsGitRepo(path) { + return fmt.Errorf(".git already exists at %s", path) + } + + // Revert + if err := git.Revert(path); err != nil { + return err + } + + // Remove from config + cfg.RemoveRepo(path) + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("✓ Reverted: %s\n", path) + return nil +} + +func revertAllRepos(cfg *config.Config) error { + if len(cfg.Repos) == 0 { + fmt.Println("No repositories to revert") + return nil + } + + fmt.Printf("Reverting %d repositories\n\n", len(cfg.Repos)) + + reverted := 0 + failed := 0 + + // Create a copy of repos to iterate over, since we'll be modifying the config + repos := make([]config.Repo, len(cfg.Repos)) + copy(repos, cfg.Repos) + + for _, repo := range repos { + if !git.IsGitLocalRepo(repo.Path) { + fmt.Fprintf(os.Stderr, "⊘ Skipped (no .gitlocal found): %s\n", repo.Path) + // Remove from config even if .gitlocal doesn't exist + cfg.RemoveRepo(repo.Path) + continue + } + + if git.IsGitRepo(repo.Path) { + fmt.Fprintf(os.Stderr, "⊘ Skipped (.git exists): %s\n", repo.Path) + continue + } + + if err := git.Revert(repo.Path); err != nil { + fmt.Fprintf(os.Stderr, "✗ Failed to revert %s: %v\n", repo.Path, err) + failed++ + continue + } + + cfg.RemoveRepo(repo.Path) + fmt.Printf("✓ Reverted: %s\n", repo.Path) + reverted++ + } + + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("\nSuccessfully reverted %d repositories", reverted) + if failed > 0 { + fmt.Printf(" (%d failed)", failed) + } + fmt.Println() + + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..a7f6080 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "gitlocal", + Short: "Manage nested git repositories", + Long: `gitlocal helps you manage nested git repositories by converting .git directories +to .gitlocal, allowing parent repos to track nested project files. + +This is useful when you have a knowledge base or monorepo that contains +multiple smaller projects, some of which use git locally.`, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + rootCmd.AddCommand(convertCmd) + rootCmd.AddCommand(revertCmd) + rootCmd.AddCommand(statusCmd) +} diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..b55c923 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + "time" + + "git.membo.co.uk/dtomlinson/gitlocal/internal/config" + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show all converted repositories", + Long: `Display a list of all repositories that have been converted from .git to .gitlocal.`, + RunE: runStatus, +} + +func runStatus(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if len(cfg.Repos) == 0 { + fmt.Println("No converted repositories") + return nil + } + + fmt.Printf("Converted Repositories (%d):\n\n", len(cfg.Repos)) + + for _, repo := range cfg.Repos { + fmt.Printf(" %s\n", repo.Path) + fmt.Printf(" Converted: %s\n", formatTime(repo.ConvertedAt)) + if repo.OriginalRemote != "" { + fmt.Printf(" Remote: %s\n", repo.OriginalRemote) + } + if repo.OriginalBranch != "" { + fmt.Printf(" Branch: %s\n", repo.OriginalBranch) + } + fmt.Println() + } + + return nil +} + +func formatTime(t time.Time) string { + now := time.Now() + diff := now.Sub(t) + + switch { + case diff < time.Minute: + return "just now" + case diff < time.Hour: + mins := int(diff.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case diff < 24*time.Hour: + hours := int(diff.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case diff < 7*24*time.Hour: + days := int(diff.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + case diff < 30*24*time.Hour: + weeks := int(diff.Hours() / 24 / 7) + if weeks == 1 { + return "1 week ago" + } + return fmt.Sprintf("%d weeks ago", weeks) + default: + months := int(diff.Hours() / 24 / 30) + if months == 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6632d9e --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.membo.co.uk/dtomlinson/gitlocal + +go 1.23.3 + +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..47edb24 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3124c30 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,111 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + ConfigVersion = "1" + ConfigFile = ".gitlocal.yml" +) + +type Repo struct { + Path string `yaml:"path"` + ConvertedAt time.Time `yaml:"converted_at"` + OriginalRemote string `yaml:"original_remote,omitempty"` + OriginalBranch string `yaml:"original_branch,omitempty"` +} + +type Config struct { + Version string `yaml:"version"` + Repos []Repo `yaml:"repos"` +} + +// GetConfigPath returns the absolute path to the config file +func GetConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ConfigFile), nil +} + +// Load reads the config file or creates a new empty config if it doesn't exist +func Load() (*Config, error) { + configPath, err := GetConfigPath() + if err != nil { + return nil, err + } + + // If config doesn't exist, return empty config + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return &Config{ + Version: ConfigVersion, + Repos: []Repo{}, + }, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + return &cfg, nil +} + +// Save writes the config to disk +func (c *Config) Save() error { + configPath, err := GetConfigPath() + if err != nil { + return err + } + + data, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// AddRepo adds a new repo to the config +func (c *Config) AddRepo(repo Repo) { + // Remove existing entry if it exists + c.RemoveRepo(repo.Path) + c.Repos = append(c.Repos, repo) +} + +// RemoveRepo removes a repo from the config by path +func (c *Config) RemoveRepo(path string) { + filtered := []Repo{} + for _, r := range c.Repos { + if r.Path != path { + filtered = append(filtered, r) + } + } + c.Repos = filtered +} + +// FindRepo returns a repo by path, or nil if not found +func (c *Config) FindRepo(path string) *Repo { + for _, r := range c.Repos { + if r.Path == path { + return &r + } + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..eedb90b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,260 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadConfigNotExists(t *testing.T) { + // Override config path to use temp file + originalHome := os.Getenv("HOME") + tempDir := t.TempDir() + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + cfg, err := Load() + if err != nil { + t.Fatalf("expected no error when config doesn't exist, got: %v", err) + } + + if cfg.Version != ConfigVersion { + t.Errorf("expected version %s, got %s", ConfigVersion, cfg.Version) + } + + if len(cfg.Repos) != 0 { + t.Errorf("expected empty repos, got %d repos", len(cfg.Repos)) + } +} + +func TestLoadConfigExists(t *testing.T) { + originalHome := os.Getenv("HOME") + tempDir := t.TempDir() + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Create a config file + configPath := filepath.Join(tempDir, ConfigFile) + configContent := `version: "1" +repos: + - path: /test/path/1 + converted_at: 2026-04-07T14:30:00Z + original_remote: git@github.com:user/repo1.git + original_branch: main + - path: /test/path/2 + converted_at: 2026-04-07T15:00:00Z + original_remote: git@github.com:user/repo2.git + original_branch: develop +` + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + if cfg.Version != "1" { + t.Errorf("expected version 1, got %s", cfg.Version) + } + + if len(cfg.Repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(cfg.Repos)) + } + + if cfg.Repos[0].Path != "/test/path/1" { + t.Errorf("expected path /test/path/1, got %s", cfg.Repos[0].Path) + } + + if cfg.Repos[0].OriginalRemote != "git@github.com:user/repo1.git" { + t.Errorf("expected remote git@github.com:user/repo1.git, got %s", cfg.Repos[0].OriginalRemote) + } +} + +func TestSaveConfig(t *testing.T) { + originalHome := os.Getenv("HOME") + tempDir := t.TempDir() + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + cfg := &Config{ + Version: ConfigVersion, + Repos: []Repo{ + { + Path: "/test/repo", + ConvertedAt: time.Now(), + OriginalRemote: "git@github.com:test/repo.git", + OriginalBranch: "main", + }, + }, + } + + if err := cfg.Save(); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + configPath := filepath.Join(tempDir, ConfigFile) + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Fatalf("config file was not created") + } + + // Load it back to verify + loadedCfg, err := Load() + if err != nil { + t.Fatalf("failed to load saved config: %v", err) + } + + if len(loadedCfg.Repos) != 1 { + t.Fatalf("expected 1 repo, got %d", len(loadedCfg.Repos)) + } + + if loadedCfg.Repos[0].Path != "/test/repo" { + t.Errorf("expected path /test/repo, got %s", loadedCfg.Repos[0].Path) + } +} + +func TestAddRepo(t *testing.T) { + cfg := &Config{ + Version: ConfigVersion, + Repos: []Repo{}, + } + + repo1 := Repo{ + Path: "/test/repo1", + ConvertedAt: time.Now(), + OriginalRemote: "git@github.com:test/repo1.git", + OriginalBranch: "main", + } + + cfg.AddRepo(repo1) + + if len(cfg.Repos) != 1 { + t.Fatalf("expected 1 repo, got %d", len(cfg.Repos)) + } + + if cfg.Repos[0].Path != "/test/repo1" { + t.Errorf("expected path /test/repo1, got %s", cfg.Repos[0].Path) + } +} + +func TestAddRepoReplaceExisting(t *testing.T) { + now := time.Now() + later := now.Add(1 * time.Hour) + + cfg := &Config{ + Version: ConfigVersion, + Repos: []Repo{ + { + Path: "/test/repo", + ConvertedAt: now, + OriginalRemote: "git@github.com:test/old.git", + OriginalBranch: "main", + }, + }, + } + + // Add same path again with different data + repo := Repo{ + Path: "/test/repo", + ConvertedAt: later, + OriginalRemote: "git@github.com:test/new.git", + OriginalBranch: "develop", + } + + cfg.AddRepo(repo) + + if len(cfg.Repos) != 1 { + t.Fatalf("expected 1 repo (replaced), got %d", len(cfg.Repos)) + } + + if cfg.Repos[0].OriginalRemote != "git@github.com:test/new.git" { + t.Errorf("expected new remote, got %s", cfg.Repos[0].OriginalRemote) + } + + if cfg.Repos[0].OriginalBranch != "develop" { + t.Errorf("expected branch develop, got %s", cfg.Repos[0].OriginalBranch) + } + + if !cfg.Repos[0].ConvertedAt.Equal(later) { + t.Errorf("expected later timestamp") + } +} + +func TestRemoveRepo(t *testing.T) { + cfg := &Config{ + Version: ConfigVersion, + Repos: []Repo{ + {Path: "/test/repo1"}, + {Path: "/test/repo2"}, + {Path: "/test/repo3"}, + }, + } + + cfg.RemoveRepo("/test/repo2") + + if len(cfg.Repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(cfg.Repos)) + } + + for _, repo := range cfg.Repos { + if repo.Path == "/test/repo2" { + t.Errorf("repo2 should have been removed") + } + } +} + +func TestRemoveRepoNotFound(t *testing.T) { + cfg := &Config{ + Version: ConfigVersion, + Repos: []Repo{ + {Path: "/test/repo1"}, + }, + } + + cfg.RemoveRepo("/test/nonexistent") + + if len(cfg.Repos) != 1 { + t.Fatalf("expected 1 repo, got %d", len(cfg.Repos)) + } +} + +func TestFindRepo(t *testing.T) { + cfg := &Config{ + Version: ConfigVersion, + Repos: []Repo{ + { + Path: "/test/repo1", + OriginalRemote: "git@github.com:test/repo1.git", + }, + { + Path: "/test/repo2", + OriginalRemote: "git@github.com:test/repo2.git", + }, + }, + } + + repo := cfg.FindRepo("/test/repo1") + if repo == nil { + t.Fatalf("expected to find repo1") + } + + if repo.OriginalRemote != "git@github.com:test/repo1.git" { + t.Errorf("expected repo1 remote, got %s", repo.OriginalRemote) + } +} + +func TestFindRepoNotFound(t *testing.T) { + cfg := &Config{ + Version: ConfigVersion, + Repos: []Repo{ + {Path: "/test/repo1"}, + }, + } + + repo := cfg.FindRepo("/test/nonexistent") + if repo != nil { + t.Errorf("expected nil for nonexistent repo, got %v", repo) + } +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..03cd1ec --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,94 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + GitDir = ".git" + GitLocalDir = ".gitlocal" +) + +// IsGitRepo checks if the path contains a .git directory +func IsGitRepo(path string) bool { + gitPath := filepath.Join(path, GitDir) + info, err := os.Stat(gitPath) + if err != nil { + return false + } + return info.IsDir() +} + +// IsGitLocalRepo checks if the path contains a .gitlocal directory +func IsGitLocalRepo(path string) bool { + gitLocalPath := filepath.Join(path, GitLocalDir) + info, err := os.Stat(gitLocalPath) + if err != nil { + return false + } + return info.IsDir() +} + +// GetRemoteURL returns the remote URL for the git repo +func GetRemoteURL(gitDir string) string { + cmd := exec.Command("git", "--git-dir", gitDir, "remote", "get-url", "origin") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +// GetCurrentBranch returns the current branch for the git repo +func GetCurrentBranch(gitDir string) string { + cmd := exec.Command("git", "--git-dir", gitDir, "branch", "--show-current") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +// Convert renames .git to .gitlocal +func Convert(path string) error { + gitPath := filepath.Join(path, GitDir) + gitLocalPath := filepath.Join(path, GitLocalDir) + + if !IsGitRepo(path) { + return fmt.Errorf("no .git directory found at %s", path) + } + + if IsGitLocalRepo(path) { + return fmt.Errorf(".gitlocal already exists at %s", path) + } + + if err := os.Rename(gitPath, gitLocalPath); err != nil { + return fmt.Errorf("failed to rename .git to .gitlocal: %w", err) + } + + return nil +} + +// Revert renames .gitlocal back to .git +func Revert(path string) error { + gitPath := filepath.Join(path, GitDir) + gitLocalPath := filepath.Join(path, GitLocalDir) + + if !IsGitLocalRepo(path) { + return fmt.Errorf("no .gitlocal directory found at %s", path) + } + + if IsGitRepo(path) { + return fmt.Errorf(".git already exists at %s", path) + } + + if err := os.Rename(gitLocalPath, gitPath); err != nil { + return fmt.Errorf("failed to rename .gitlocal to .git: %w", err) + } + + return nil +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..88fed46 --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,372 @@ +package git + +import ( + "os" + "path/filepath" + "testing" + + "git.membo.co.uk/dtomlinson/gitlocal/internal/testutil" +) + +func TestIsGitRepo(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expected bool + }{ + { + name: "valid git repo", + setup: func(t *testing.T) string { + return testutil.CreateTempGitRepo(t) + }, + expected: true, + }, + { + name: "not a git repo", + setup: func(t *testing.T) string { + return t.TempDir() + }, + expected: false, + }, + { + name: "has .gitlocal but not .git", + setup: func(t *testing.T) string { + dir := t.TempDir() + gitLocalDir := filepath.Join(dir, GitLocalDir) + if err := os.Mkdir(gitLocalDir, 0755); err != nil { + t.Fatalf("failed to create .gitlocal: %v", err) + } + return dir + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + result := IsGitRepo(path) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestIsGitLocalRepo(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expected bool + }{ + { + name: "has .gitlocal", + setup: func(t *testing.T) string { + dir := t.TempDir() + gitLocalDir := filepath.Join(dir, GitLocalDir) + if err := os.Mkdir(gitLocalDir, 0755); err != nil { + t.Fatalf("failed to create .gitlocal: %v", err) + } + return dir + }, + expected: true, + }, + { + name: "has .git but not .gitlocal", + setup: func(t *testing.T) string { + return testutil.CreateTempGitRepo(t) + }, + expected: false, + }, + { + name: "has neither", + setup: func(t *testing.T) string { + return t.TempDir() + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + result := IsGitLocalRepo(path) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestGetRemoteURL(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expected string + }{ + { + name: "repo with remote", + setup: func(t *testing.T) string { + dir := testutil.CreateTempGitRepoWithRemote(t, "git@github.com:test/repo.git") + return filepath.Join(dir, GitDir) + }, + expected: "git@github.com:test/repo.git", + }, + { + name: "repo without remote", + setup: func(t *testing.T) string { + dir := testutil.CreateTempGitRepo(t) + return filepath.Join(dir, GitDir) + }, + expected: "", + }, + { + name: "invalid git dir", + setup: func(t *testing.T) string { + return "/nonexistent/.git" + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitDir := tt.setup(t) + result := GetRemoteURL(gitDir) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestGetCurrentBranch(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expected string + }{ + { + name: "default branch (master or main)", + setup: func(t *testing.T) string { + dir := testutil.CreateTempGitRepo(t) + return filepath.Join(dir, GitDir) + }, + // Git might use master or main depending on config + expected: "", // We'll check it's non-empty instead + }, + { + name: "custom branch", + setup: func(t *testing.T) string { + dir := testutil.CreateTempGitRepoWithBranch(t, "develop") + return filepath.Join(dir, GitDir) + }, + expected: "develop", + }, + { + name: "invalid git dir", + setup: func(t *testing.T) string { + return "/nonexistent/.git" + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitDir := tt.setup(t) + result := GetCurrentBranch(gitDir) + + if tt.name == "default branch (master or main)" { + // Just check it returned something + if result == "" { + t.Errorf("expected non-empty branch name, got empty string") + } + } else if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestConvert(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expectErr bool + }{ + { + name: "valid git repo", + setup: func(t *testing.T) string { + return testutil.CreateTempGitRepo(t) + }, + expectErr: false, + }, + { + name: "no .git directory", + setup: func(t *testing.T) string { + return t.TempDir() + }, + expectErr: true, + }, + { + name: ".gitlocal already exists", + setup: func(t *testing.T) string { + dir := testutil.CreateTempGitRepo(t) + gitLocalDir := filepath.Join(dir, GitLocalDir) + if err := os.Mkdir(gitLocalDir, 0755); err != nil { + t.Fatalf("failed to create .gitlocal: %v", err) + } + return dir + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + err := Convert(path) + + if tt.expectErr && err == nil { + t.Errorf("expected error, got nil") + } + + if !tt.expectErr && err != nil { + t.Errorf("expected no error, got %v", err) + } + + if !tt.expectErr { + // Verify .git was renamed to .gitlocal + gitPath := filepath.Join(path, GitDir) + gitLocalPath := filepath.Join(path, GitLocalDir) + + if _, err := os.Stat(gitPath); !os.IsNotExist(err) { + t.Errorf(".git directory still exists") + } + + if _, err := os.Stat(gitLocalPath); os.IsNotExist(err) { + t.Errorf(".gitlocal directory does not exist") + } + } + }) + } +} + +func TestRevert(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expectErr bool + }{ + { + name: "valid .gitlocal directory", + setup: func(t *testing.T) string { + dir := testutil.CreateTempGitRepo(t) + // Convert to .gitlocal + if err := Convert(dir); err != nil { + t.Fatalf("failed to convert: %v", err) + } + return dir + }, + expectErr: false, + }, + { + name: "no .gitlocal directory", + setup: func(t *testing.T) string { + return t.TempDir() + }, + expectErr: true, + }, + { + name: ".git already exists", + setup: func(t *testing.T) string { + dir := testutil.CreateTempGitRepo(t) + gitLocalDir := filepath.Join(dir, GitLocalDir) + if err := os.Mkdir(gitLocalDir, 0755); err != nil { + t.Fatalf("failed to create .gitlocal: %v", err) + } + return dir + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + err := Revert(path) + + if tt.expectErr && err == nil { + t.Errorf("expected error, got nil") + } + + if !tt.expectErr && err != nil { + t.Errorf("expected no error, got %v", err) + } + + if !tt.expectErr { + // Verify .gitlocal was renamed to .git + gitPath := filepath.Join(path, GitDir) + gitLocalPath := filepath.Join(path, GitLocalDir) + + if _, err := os.Stat(gitLocalPath); !os.IsNotExist(err) { + t.Errorf(".gitlocal directory still exists") + } + + if _, err := os.Stat(gitPath); os.IsNotExist(err) { + t.Errorf(".git directory does not exist") + } + } + }) + } +} + +func TestConvertRevertRoundTrip(t *testing.T) { + dir := testutil.CreateTempGitRepoWithRemote(t, "git@github.com:test/repo.git") + + // Initial state - should have .git + if !IsGitRepo(dir) { + t.Fatal("expected .git to exist initially") + } + + // Convert + if err := Convert(dir); err != nil { + t.Fatalf("convert failed: %v", err) + } + + // Should have .gitlocal, not .git + if IsGitRepo(dir) { + t.Error("expected .git to be gone after convert") + } + if !IsGitLocalRepo(dir) { + t.Error("expected .gitlocal to exist after convert") + } + + // Remote should still be accessible via .gitlocal + gitLocalPath := filepath.Join(dir, GitLocalDir) + remote := GetRemoteURL(gitLocalPath) + if remote != "git@github.com:test/repo.git" { + t.Errorf("expected remote to be preserved, got %q", remote) + } + + // Revert + if err := Revert(dir); err != nil { + t.Fatalf("revert failed: %v", err) + } + + // Should have .git again, not .gitlocal + if !IsGitRepo(dir) { + t.Error("expected .git to exist after revert") + } + if IsGitLocalRepo(dir) { + t.Error("expected .gitlocal to be gone after revert") + } + + // Remote should still be accessible + gitPath := filepath.Join(dir, GitDir) + remote = GetRemoteURL(gitPath) + if remote != "git@github.com:test/repo.git" { + t.Errorf("expected remote to be preserved after round trip, got %q", remote) + } +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..22190ee --- /dev/null +++ b/internal/scanner/scanner.go @@ -0,0 +1,59 @@ +package scanner + +import ( + "os" + "path/filepath" + + "git.membo.co.uk/dtomlinson/gitlocal/internal/git" +) + +// FindNestedGitRepos recursively scans rootPath for nested .git and .gitlocal directories +// and returns a list of absolute paths to directories containing .git or .gitlocal +// It skips the root repo's .git/.gitlocal directory if one exists +func FindNestedGitRepos(rootPath string) ([]string, error) { + var repos []string + + // Get absolute path + absRoot, err := filepath.Abs(rootPath) + if err != nil { + return nil, err + } + + // Check if root itself is a git repo (either .git or .gitlocal) + rootIsGitRepo := git.IsGitRepo(absRoot) || git.IsGitLocalRepo(absRoot) + rootGitPath := filepath.Join(absRoot, git.GitDir) + rootGitLocalPath := filepath.Join(absRoot, git.GitLocalDir) + + err = filepath.Walk(absRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip if not a directory + if !info.IsDir() { + return nil + } + + // Skip if this is the root's .git or .gitlocal directory + if rootIsGitRepo && (path == rootGitPath || path == rootGitLocalPath) { + return filepath.SkipDir + } + + // Check if this directory is named .git or .gitlocal + if info.Name() == git.GitDir || info.Name() == git.GitLocalDir { + // Get the parent directory (the actual repo path) + repoPath := filepath.Dir(path) + repos = append(repos, repoPath) + // Skip descending into .git/.gitlocal directory + return filepath.SkipDir + } + + return nil + }) + + if err != nil { + return nil, err + } + + return repos, nil +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..0c75028 --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -0,0 +1,225 @@ +package scanner + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "git.membo.co.uk/dtomlinson/gitlocal/internal/git" + "git.membo.co.uk/dtomlinson/gitlocal/internal/testutil" +) + +func TestFindNestedGitRepos(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) (rootDir string, expectedPaths []string) + expectedCount int + }{ + { + name: "no nested repos", + setup: func(t *testing.T) (string, []string) { + dir := testutil.CreateTempGitRepo(t) + return dir, []string{} + }, + expectedCount: 0, + }, + { + name: "nested repos structure", + setup: func(t *testing.T) (string, []string) { + rootDir, nestedDirs := testutil.CreateNestedRepoStructure(t) + return rootDir, nestedDirs + }, + expectedCount: 3, + }, + { + name: "non-git directory", + setup: func(t *testing.T) (string, []string) { + dir := t.TempDir() + // Create subdirectories without .git + os.MkdirAll(filepath.Join(dir, "subdir1"), 0755) + os.MkdirAll(filepath.Join(dir, "subdir2", "deep"), 0755) + return dir, []string{} + }, + expectedCount: 0, + }, + { + name: "mixed nested repos and regular dirs", + setup: func(t *testing.T) (string, []string) { + dir := testutil.CreateTempGitRepo(t) + + // Create one nested git repo + nested1 := filepath.Join(dir, "nested1") + os.MkdirAll(nested1, 0755) + testutil.CreateTempGitRepo(t) // This creates in temp dir, we need to manually init + // Let's use a helper to init git in nested1 + initGitInDir(t, nested1) + + // Create regular directories + os.MkdirAll(filepath.Join(dir, "regular"), 0755) + os.MkdirAll(filepath.Join(dir, "another", "deep"), 0755) + + return dir, []string{nested1} + }, + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootDir, expectedPaths := tt.setup(t) + + repos, err := FindNestedGitRepos(rootDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repos) != tt.expectedCount { + t.Errorf("expected %d repos, got %d: %v", tt.expectedCount, len(repos), repos) + } + + if tt.expectedCount > 0 { + // Sort both slices for comparison + sort.Strings(repos) + sort.Strings(expectedPaths) + + for i, expected := range expectedPaths { + if i >= len(repos) { + t.Errorf("missing expected repo: %s", expected) + continue + } + if repos[i] != expected { + t.Errorf("expected repo %s, got %s", expected, repos[i]) + } + } + } + }) + } +} + +func TestFindNestedGitReposSkipsRootGit(t *testing.T) { + rootDir := testutil.CreateTempGitRepo(t) + + // Create nested repos + nested1 := filepath.Join(rootDir, "nested1") + os.MkdirAll(nested1, 0755) + initGitInDir(t, nested1) + + repos, err := FindNestedGitRepos(rootDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find nested1 but not the root + if len(repos) != 1 { + t.Fatalf("expected 1 nested repo, got %d", len(repos)) + } + + if repos[0] != nested1 { + t.Errorf("expected %s, got %s", nested1, repos[0]) + } +} + +func TestFindNestedGitReposWithGitLocal(t *testing.T) { + rootDir := testutil.CreateTempGitRepo(t) + + // Create nested repo with .gitlocal + nested1 := filepath.Join(rootDir, "nested1") + os.MkdirAll(nested1, 0755) + initGitInDir(t, nested1) + // Convert to .gitlocal + if err := git.Convert(nested1); err != nil { + t.Fatalf("failed to convert: %v", err) + } + + // Create nested repo with .git + nested2 := filepath.Join(rootDir, "nested2") + os.MkdirAll(nested2, 0755) + initGitInDir(t, nested2) + + repos, err := FindNestedGitRepos(rootDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find both + if len(repos) != 2 { + t.Fatalf("expected 2 nested repos, got %d", len(repos)) + } + + sort.Strings(repos) + expectedPaths := []string{nested1, nested2} + sort.Strings(expectedPaths) + + for i, expected := range expectedPaths { + if repos[i] != expected { + t.Errorf("expected repo %s, got %s", expected, repos[i]) + } + } +} + +func TestFindNestedGitReposWithRootGitLocal(t *testing.T) { + rootDir := testutil.CreateTempGitRepo(t) + + // Convert root to .gitlocal + if err := git.Convert(rootDir); err != nil { + t.Fatalf("failed to convert root: %v", err) + } + + // Create nested repo + nested1 := filepath.Join(rootDir, "nested1") + os.MkdirAll(nested1, 0755) + initGitInDir(t, nested1) + + repos, err := FindNestedGitRepos(rootDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find nested1 but not the root .gitlocal + if len(repos) != 1 { + t.Fatalf("expected 1 nested repo, got %d", len(repos)) + } + + if repos[0] != nested1 { + t.Errorf("expected %s, got %s", nested1, repos[0]) + } +} + +func TestFindNestedGitReposDeepNesting(t *testing.T) { + rootDir := testutil.CreateTempGitRepo(t) + + // Create deeply nested structure + deep := filepath.Join(rootDir, "a", "b", "c", "d", "nested") + os.MkdirAll(deep, 0755) + initGitInDir(t, deep) + + repos, err := FindNestedGitRepos(rootDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repos) != 1 { + t.Fatalf("expected 1 nested repo, got %d", len(repos)) + } + + if repos[0] != deep { + t.Errorf("expected %s, got %s", deep, repos[0]) + } +} + +// Helper function to initialize git in a directory +func initGitInDir(t *testing.T, dir string) { + t.Helper() + + // We can't use testutil.CreateTempGitRepo since it creates a new temp dir + // So we manually init git here + gitDir := filepath.Join(dir, git.GitDir) + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatalf("failed to create .git directory: %v", err) + } + + // Create minimal git structure + // For the scanner, we just need the .git directory to exist + // The scanner doesn't validate the git repo structure +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go new file mode 100644 index 0000000..48755aa --- /dev/null +++ b/internal/testutil/testutil.go @@ -0,0 +1,158 @@ +package testutil + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// CreateTempGitRepo creates a temporary directory with a git repository +func CreateTempGitRepo(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + // Configure git user for the test repo + configName := exec.Command("git", "config", "user.name", "Test User") + configName.Dir = dir + if err := configName.Run(); err != nil { + t.Fatalf("failed to configure git user.name: %v", err) + } + + configEmail := exec.Command("git", "config", "user.email", "test@example.com") + configEmail.Dir = dir + if err := configEmail.Run(); err != nil { + t.Fatalf("failed to configure git user.email: %v", err) + } + + // Create initial commit + readmePath := filepath.Join(dir, "README.md") + if err := os.WriteFile(readmePath, []byte("# Test Repo\n"), 0644); err != nil { + t.Fatalf("failed to create README: %v", err) + } + + add := exec.Command("git", "add", "README.md") + add.Dir = dir + if err := add.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + commit := exec.Command("git", "commit", "-m", "Initial commit") + commit.Dir = dir + if err := commit.Run(); err != nil { + t.Fatalf("failed to git commit: %v", err) + } + + return dir +} + +// CreateTempGitRepoWithRemote creates a temporary git repo with a remote URL configured +func CreateTempGitRepoWithRemote(t *testing.T, remoteURL string) string { + t.Helper() + + dir := CreateTempGitRepo(t) + + // Add remote + cmd := exec.Command("git", "remote", "add", "origin", remoteURL) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to add git remote: %v", err) + } + + return dir +} + +// CreateTempGitRepoWithBranch creates a temporary git repo on a specific branch +func CreateTempGitRepoWithBranch(t *testing.T, branch string) string { + t.Helper() + + dir := CreateTempGitRepo(t) + + // Create and checkout branch + cmd := exec.Command("git", "checkout", "-b", branch) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + + return dir +} + +// CreateNestedRepoStructure creates a directory structure with nested git repos +// Returns the root directory path +func CreateNestedRepoStructure(t *testing.T) (rootDir string, nestedDirs []string) { + t.Helper() + + rootDir = CreateTempGitRepo(t) + + // Create nested repos + nested1 := filepath.Join(rootDir, "project1") + if err := os.MkdirAll(nested1, 0755); err != nil { + t.Fatalf("failed to create nested dir: %v", err) + } + initGit(t, nested1) + + nested2 := filepath.Join(rootDir, "subdir", "project2") + if err := os.MkdirAll(nested2, 0755); err != nil { + t.Fatalf("failed to create nested dir: %v", err) + } + initGit(t, nested2) + + nested3 := filepath.Join(rootDir, "subdir", "deep", "project3") + if err := os.MkdirAll(nested3, 0755); err != nil { + t.Fatalf("failed to create nested dir: %v", err) + } + initGit(t, nested3) + + return rootDir, []string{nested1, nested2, nested3} +} + +// initGit initializes a git repo in the given directory +func initGit(t *testing.T, dir string) { + t.Helper() + + cmd := exec.Command("git", "init") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + // Configure git user + configName := exec.Command("git", "config", "user.name", "Test User") + configName.Dir = dir + if err := configName.Run(); err != nil { + t.Fatalf("failed to configure git user.name: %v", err) + } + + configEmail := exec.Command("git", "config", "user.email", "test@example.com") + configEmail.Dir = dir + if err := configEmail.Run(); err != nil { + t.Fatalf("failed to configure git user.email: %v", err) + } + + // Create initial commit + readmePath := filepath.Join(dir, "README.md") + if err := os.WriteFile(readmePath, []byte("# Test\n"), 0644); err != nil { + t.Fatalf("failed to create README: %v", err) + } + + add := exec.Command("git", "add", "README.md") + add.Dir = dir + if err := add.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + commit := exec.Command("git", "commit", "-m", "Initial commit") + commit.Dir = dir + if err := commit.Run(); err != nil { + t.Fatalf("failed to git commit: %v", err) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7f38be7 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "git.membo.co.uk/dtomlinson/gitlocal/cmd" + +func main() { + cmd.Execute() +}