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`.
This commit is contained in:
2026-04-11 14:48:01 +01:00
parent c9867f410f
commit b5f1495680
16 changed files with 2263 additions and 0 deletions

View File

@@ -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
}