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:
59
internal/scanner/scanner.go
Normal file
59
internal/scanner/scanner.go
Normal file
@@ -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
|
||||
}
|
||||
225
internal/scanner/scanner_test.go
Normal file
225
internal/scanner/scanner_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user