From d3647b71d79b7099cf1a64f5a8150fe73c16a5f6 Mon Sep 17 00:00:00 2001 From: juancwu <46619361+juancwu@users.noreply.github.com> Date: Thu, 1 Jan 2026 12:23:18 -0500 Subject: [PATCH] basic clone capabilities --- go.mod | 3 ++ main.go | 119 +++++++++++++++++++++++++++++++++++++++++++++++ parseurl_test.go | 105 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 go.mod create mode 100644 main.go create mode 100644 parseurl_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bf1c1a9 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.juancwu.dev/juancwu/lazyclone + +go 1.25.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..942de99 --- /dev/null +++ b/main.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +const GHQDir = "ghq" + +// GitURL holds the parsed structure of the repository +type GitURL struct { + Original string + Host string + Path string // This includes namespace/repo (e.g. "juancwu/tools") +} + +func main() { + args := os.Args[1:] + + if len(args) != 1 { + printUsage() + os.Exit(1) + } + + rawURL := args[0] + + repoInfo, err := parseGitURL(rawURL) + if err != nil { + fmt.Printf("Error parsing URL: %v\n", err) + os.Exit(1) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error getting home directory: %v\n", err) + os.Exit(1) + } + + targetDir := filepath.Join(homeDir, GHQDir, repoInfo.Host, repoInfo.Path) + + if err := cloneRepo(repoInfo.Original, targetDir); err != nil { + os.Exit(1) + } +} + +// parseGitURL extracts the Host and Path from SSH (SCP-syntax) or HTTP/Standard URLs +func parseGitURL(raw string) (*GitURL, error) { + raw = strings.TrimSpace(raw) + + // Regex for SCP-like syntax: user@host:path/to/repo.git + // Matches: git@github.com:user/repo.git + scpSyntax := regexp.MustCompile(`^[\w-]+@([^:]+):(.+?)(?:\.git)?$`) + + if match := scpSyntax.FindStringSubmatch(raw); match != nil { + return &GitURL{ + Original: raw, + Host: match[1], + Path: match[2], + }, nil + } + + // Standard URL parsing for http, https, ssh:// + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid URL format: %w", err) + } + + if u.Host == "" { + return nil, fmt.Errorf("URL is missing a host (e.g. github.com)") + } + + cleanPath := strings.TrimPrefix(u.Path, "/") + cleanPath = strings.TrimSuffix(cleanPath, ".git") + + return &GitURL{ + Original: raw, + Host: u.Host, + Path: cleanPath, + }, nil +} + +func cloneRepo(url, targetDir string) error { + // Check if directory already exists + if _, err := os.Stat(targetDir); !os.IsNotExist(err) { + fmt.Printf("Directory already exists: %s\n", targetDir) + return nil + } + + fmt.Printf("Cloning into: %s\n", targetDir) + + cmd := exec.Command("git", "clone", url, targetDir) + // Pipe git output directly to the user's terminal + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + return nil +} + +func printUsage() { + fmt.Println(`Usage: cl + +Description: + Clones a git repository into a structured directory tree: + ~/ghq/// + +Examples: + cl git@github.com:user/project.git + cl https://gitlab.com/group/subgroup/project.git + cl https://git.company.corp/devops/tools.git`) +} diff --git a/parseurl_test.go b/parseurl_test.go new file mode 100644 index 0000000..3112df0 --- /dev/null +++ b/parseurl_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "testing" +) + +func TestParseGitURL(t *testing.T) { + tests := []struct { + name string + inputURL string + expectedHost string + expectedPath string + expectError bool + }{ + // 1. Standard HTTPS (Github) + { + name: "HTTPS Github", + inputURL: "https://github.com/juancwu/dotfiles", + expectedHost: "github.com", + expectedPath: "juancwu/dotfiles", + }, + // 2. SSH Short Syntax (Github) + { + name: "SSH Github", + inputURL: "git@github.com:juancwu/dotfiles", + expectedHost: "github.com", + expectedPath: "juancwu/dotfiles", + }, + // 3. SSH with .git extension + { + name: "SSH Github with .git", + inputURL: "git@github.com:juancwu/dotfiles.git", + expectedHost: "github.com", + expectedPath: "juancwu/dotfiles", + }, + // 4. SSH with Tilde (Home directory reference) + // Note: The parser captures the path literally. + { + name: "SSH Github Tilde", + inputURL: "git@github.com:~/juancwu/dotfiles", + expectedHost: "github.com", + expectedPath: "~/juancwu/dotfiles", + }, + // 5. Custom Domain / Subdomain + { + name: "Custom Domain SSH", + inputURL: "git@git.juancwu.dev:juancwu/dotfiles", + expectedHost: "git.juancwu.dev", + expectedPath: "juancwu/dotfiles", + }, + // 6. Deeply Nested (Gitlab Subgroups) + { + name: "Gitlab Nested Group HTTPS", + inputURL: "https://gitlab.com/organization/backend/services/auth.git", + expectedHost: "gitlab.com", + expectedPath: "organization/backend/services/auth", + }, + // 7. Standard SSH Scheme (ssh://) + { + name: "SSH Scheme Standard", + inputURL: "ssh://git@github.com/juancwu/dotfiles", + expectedHost: "github.com", + expectedPath: "juancwu/dotfiles", + }, + // 8. IP Address Host + { + name: "IP Address Host", + inputURL: "http://192.168.1.50/user/repo.git", + expectedHost: "192.168.1.50", + expectedPath: "user/repo", + }, + // 9. Invalid URL + { + name: "Empty String", + inputURL: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseGitURL(tt.inputURL) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error for input '%s', but got nil", tt.inputURL) + } + return + } + + if err != nil { + t.Errorf("Unexpected error for input '%s': %v", tt.inputURL, err) + return + } + + if result.Host != tt.expectedHost { + t.Errorf("Host mismatch.\nExpected: %s\nGot: %s", tt.expectedHost, result.Host) + } + + if result.Path != tt.expectedPath { + t.Errorf("Path mismatch.\nExpected: %s\nGot: %s", tt.expectedPath, result.Path) + } + }) + } +}