Files
gitlocal/cmd/convert.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

220 lines
5.1 KiB
Go

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
}