diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml new file mode 100644 index 0000000..cd88554 --- /dev/null +++ b/.github/workflows/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.gitignore b/.gitignore index 4597c7d..7abedec 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,9 @@ go.work.sum .env # Binaries -$PROJECT_NAME +gclone dist # Demo -demo/$PROJECT_NAME.cast -demo/$PROJECT_NAME.gif \ No newline at end of file +demo/gclone.cast +demo/gclone.gif \ No newline at end of file diff --git a/Makefile b/Makefile index 700d42b..4b6c1a1 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "v0. COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") BUILD_USER ?= $(shell whoami)@$(shell hostname) -PKG := github.com/user-cube/$PROJECT_NAME +PKG := github.com/user-cube/gclone LDFLAGS := -ldflags "-X $(PKG)/cmd.Version=$(VERSION) -X $(PKG)/cmd.BuildDate=$(BUILD_DATE) -X $(PKG)/cmd.GitCommit=$(COMMIT) -X $(PKG)/cmd.BuildUser=$(BUILD_USER)" .PHONY: all @@ -10,18 +10,18 @@ all: clean build .PHONY: build build: - @echo "Building $PROJECT_NAME $(VERSION) ($(COMMIT))" - @go build $(LDFLAGS) -o $PROJECT_NAME main.go + @echo "Building gclone $(VERSION) ($(COMMIT))" + @go build $(LDFLAGS) -o gclone main.go .PHONY: install install: - @echo "Installing $PROJECT_NAME $(VERSION) to GOPATH" + @echo "Installing gclone $(VERSION) to GOPATH" @go install $(LDFLAGS) .PHONY: clean clean: @echo "Cleaning build artifacts" - @rm -f $PROJECT_NAME + @rm -f gclone @rm -rf dist .PHONY: test @@ -58,26 +58,26 @@ release-snapshot: clean .PHONY: build-release build-release: clean - @echo "Building release version of $PROJECT_NAME $(VERSION) ($(COMMIT))" - @go build $(LDFLAGS) -o $PROJECT_NAME main.go - @echo "Built $PROJECT_NAME binary with release information" + @echo "Building release version of gclone $(VERSION) ($(COMMIT))" + @go build $(LDFLAGS) -o gclone main.go + @echo "Built gclone binary with release information" @echo "Version: $(VERSION)" @echo "Commit: $(COMMIT)" @echo "Build Date: $(BUILD_DATE)" - @echo "Run ./$PROJECT_NAME version to verify" + @echo "Run ./gclone version to verify" .PHONY: help help: - @echo "$PROJECT_NAME Makefile" + @echo "GClone Makefile" @echo "---------------" @echo "Available targets:" - @echo " all - Clean and build $PROJECT_NAME" - @echo " build - Build the $PROJECT_NAME binary" - @echo " install - Install $PROJECT_NAME to your GOPATH/bin" + @echo " all - Clean and build gclone" + @echo " build - Build the gclone binary" + @echo " install - Install gclone to your GOPATH/bin" @echo " clean - Remove built binary and dist directory" @echo " test - Run tests" @echo " lint - Run linters (requires golangci-lint)" @echo " release - Create a full release using GoReleaser" @echo " release-snapshot - Create a local release snapshot for testing (no publish)" - @echo " build-release - Build $PROJECT_NAME binary with release information" + @echo " build-release - Build gclone binary with release information" @echo " help - Show this help message" \ No newline at end of file diff --git a/README.md b/README.md index 7d5d080..360e4de 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,222 @@ -# $PROJECT_NAME +# GClone - Git Repository Cloning Tool -Command description +GClone is a command-line tool that helps you manage Git repository clones with multiple profiles. It allows you to define different SSH hosts and Git configurations for different Git accounts (e.g., personal, work), and automatically applies the appropriate settings when cloning repositories. -![demo](/demo/$PROJECT_NAME-demo.gif) \ No newline at end of file +![demo](/demo/gclone-demo.gif) + +## Features + +- Clone repositories using different SSH configurations based on profiles +- Automatically applies Git configurations per profile (username, email, etc.) +- Supports different profile configurations for work, personal, and other accounts +- Colorful and easy-to-use CLI interface with a dedicated UI package + +## Installation + +```bash +# Clone the repository +git clone git@github.com:user-cube/gclone.git + +# Build and install +cd gclone +go install +``` + +## Usage + +### Initialize Configuration + +```bash +# Initialize the default configuration +gclone init +``` + +This creates a configuration file at `~/.gclone/config.yml` with some sample profiles. + +### Manage Profiles + +```bash +# List all profiles +gclone profile list + +# Add a new profile +gclone profile add personal --ssh-host=git-personal --git-username="Your Name" --git-email="your.email@example.com" + +# Add a profile with URL patterns for automatic detection +gclone profile add personal --ssh-host=git-personal --git-username="Your Name" --git-email="your.email@example.com" --url-pattern="github.com/your-username" --url-pattern="gitlab.com/your-username" + +# Remove a profile +gclone profile remove personal +``` + +### Clone Repositories + +```bash +# Clone a repository using a specific profile +gclone clone git@github.com:user/repo.git --profile=personal + +# Clone with automatic profile detection based on URL patterns +gclone clone git@github.com:your-personal-username/repo.git +# GClone will automatically use the personal profile if the URL matches a configured pattern + +# Clone with additional options +gclone clone git@gitlab.com:user/repo.git my-repo --profile=work --depth=1 --branch=main +``` + +> **Note:** GClone only supports SSH URLs (git@github.com:user/repo.git format). HTTP/HTTPS URLs are not supported. + +### View Configuration + +```bash +# View the current configuration +gclone config +``` + +## How It Works + +When you clone a repository with GClone, it: + +1. Reads your profile configuration from `~/.gclone/config.yml` +2. Attempts to automatically detect the appropriate profile based on URL patterns +3. Transforms the repository URL to use the specified SSH host for that profile +4. Clones the repository using the modified URL +5. Applies any Git configurations specified in the profile to the cloned repository + +For example, if you have a profile named `personal` with an SSH host of `git-personal`, when you clone `git@github.com:user/repo.git`, GClone will automatically change it to `git@git-personal:user/repo.git`. + +> **Note:** GClone only works with SSH URLs in the format `git@github.com:user/repo.git`. + +### URL Pattern Matching + +GClone can automatically select the appropriate profile based on URL patterns. For example: + +- If you have a personal profile with a URL pattern of `github.com/your-username` +- When you run `gclone clone git@github.com:your-username/repo.git` +- GClone will automatically use your personal profile without you needing to specify `--profile=personal` + +## UI Package + +GClone includes a dedicated UI package that provides consistent user interface components across the application. The UI package includes: + +- **Colors**: Colorful output for different types of messages (success, info, warning, error) +- **Prompts**: Interactive prompts for user input (selection lists, confirmations, text input) +- **Operations**: Functions for displaying operation status and progress +- **Tables**: Simple text-based tables for displaying structured data +- **Spinners**: Progress indicators for long-running operations + +### Using the UI Package + +If you're developing for GClone, you can use the UI package in your code like this: + +```go +import "github.com/user-cube/gclone/pkg/ui" + +// Display colorful messages +ui.Success("Operation completed successfully") +ui.Info("Processing %s", ui.Highlight("important information")) +ui.Warning("This might take a while") +ui.Error("Error occurred: %v", err) + +// Interactive prompts +selected, _ := ui.SelectFromList("Choose an option", []string{"Option 1", "Option 2"}) +confirmed, _ := ui.Confirm("Are you sure?") +input, _ := ui.PromptInput("Enter your name", "", nil) + +// Operation status +ui.OperationInfo("Cloning", "personal", map[string]string{ + "URL": "git@github.com:user/repo.git", +}) +ui.OperationSuccess("Repository cloned successfully") +``` + +See `examples/ui_examples.go` for more examples of using the UI package. + +## Configuration + +The configuration file is stored at `~/.gclone/config.yml` and has the following structure: + +```yaml +profiles: + personal: + name: Personal + ssh_host: git-personal + url_patterns: + - github.com/your-personal-username + - github.com:your-personal-username + git_configs: + user.name: Your Name + user.email: your.email@example.com + work: + name: Work + ssh_host: git-work + url_patterns: + - github.com/your-work-organization + - github.com:your-work-organization + git_configs: + user.name: Your Work Name + user.email: your.work.email@example.com +``` + +## SSH Configuration + +GClone can help you manage your SSH configurations automatically. For each profile, GClone can generate and maintain the necessary SSH host configuration. + +### Using the SSH Config Command + +GClone provides a built-in command to create or update SSH configurations: + +```bash +# Generate SSH config for a specific profile +gclone ssh-config personal + +# Or run without arguments to select from available profiles +gclone ssh-config +``` + +This command will: + +1. Create a `~/.gclone/ssh_config` file with the SSH host configuration for your profile +2. Add an `Include ~/.gclone/ssh_config` directive to your main `~/.ssh/config` file if it doesn't exist + +The generated configuration will look like this: + +``` +# personal profile (added by gclone) +Host github.com-personal + Hostname github.com + AddKeysToAgent yes + UseKeychain yes + IdentityFile ~/.ssh/github_personal +``` + +### Manual Configuration + +If you prefer to set up SSH configurations manually, you can add them directly to your `~/.ssh/config` file: + +``` +# Personal GitHub account +Host github.com-personal + Hostname github.com + AddKeysToAgent yes + UseKeychain yes + IdentityFile ~/.ssh/github_personal + +# Work GitHub account +Host github.com-work + Hostname github.com + AddKeysToAgent yes + UseKeychain yes + IdentityFile ~/.ssh/github_work +``` + +In this example: + +- `Host github.com-personal` defines the alias that matches the `ssh_host` in your GClone profile +- `Hostname github.com` specifies the actual Git server to connect to +- `IdentityFile ~/.ssh/github_personal` specifies which SSH key to use for this host + +When GClone transforms a URL from `git@github.com:user/repo.git` to `git@github.com-personal:user/repo.git`, SSH will use the configuration defined for the `github.com-personal` host. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/cmd/clone.go b/cmd/clone.go new file mode 100644 index 0000000..89f5e81 --- /dev/null +++ b/cmd/clone.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/user-cube/gclone/pkg/config" + "github.com/user-cube/gclone/pkg/git" + "github.com/user-cube/gclone/pkg/ui" +) + +// cloneCmd represents the clone command +var cloneCmd = &cobra.Command{ + Use: "clone [url] [destination]", + Short: "Clone a git repository with a specific profile", + Long: `Clone a git repository with a specific profile. +This will transform the repository URL to use the specified SSH host, +and apply any Git configurations specified in the profile. +Only SSH URL format (git@github.com:user/repo.git) is supported.`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Load config + configFile, _ := cmd.Flags().GetString("config") + cfg, err := config.LoadConfig(configFile) + if err != nil { + ui.Error("Error loading configuration: %v", err) + return + } + + if len(cfg.Profiles) == 0 { + ui.Warning("No profiles found in configuration. Run 'gclone init' to create default profiles.") + return + } + + // Get URL and destination + url := args[0] + var destination string + if len(args) > 1 { + destination = args[1] + } + + // Get profile + profileName, _ := cmd.Flags().GetString("profile") + + // If no profile specified, try to detect it from the URL + if profileName == "" { + detectedProfile, found := git.DetectProfileForURL(url, cfg.Profiles) + if found { + profileName = detectedProfile + ui.Info("Automatically detected profile: %s", ui.Highlight(profileName)) + } else { + // If no profile detected, prompt user to select one + profileNames := make([]string, 0, len(cfg.Profiles)) + for name := range cfg.Profiles { + profileNames = append(profileNames, name) + } + + selectedProfile, err := ui.SelectFromList("Select profile", profileNames) + if err != nil { + ui.Error("Prompt failed: %v", err) + return + } + + profileName = selectedProfile + } + } + + // Check if profile exists + profile, ok := cfg.Profiles[profileName] + if !ok { + ui.Error("Profile '%s' not found", profileName) + return + } + + // Collect extra git args + var extraArgs []string + + // Check for depth flag + depth, _ := cmd.Flags().GetInt("depth") + if depth > 0 { + extraArgs = append(extraArgs, fmt.Sprintf("--depth=%d", depth)) + } + + // Check for branch flag + branch, _ := cmd.Flags().GetString("branch") + if branch != "" { + extraArgs = append(extraArgs, fmt.Sprintf("--branch=%s", branch)) + } + + // Pass through any additional flags after -- + afterDoubleHyphen, found := findArgsAfterDoubleHyphen(os.Args) + if found { + extraArgs = append(extraArgs, afterDoubleHyphen...) + } + + // Display information about the clone operation + transformedURL, _ := git.TransformGitURL(url, &profile) + + details := map[string]string{ + "Original URL": url, + "Transformed URL": transformedURL, + } + + ui.OperationInfo("Cloning", profileName, details) + + if len(profile.GitConfigs) > 0 { + ui.Info("Git configs to apply:") + for key, value := range profile.GitConfigs { + ui.PrintKeyValue(key, value) + } + ui.Normal("\n") + } + + // Clone the repository + err = git.CloneRepository(url, destination, &profile, extraArgs) + if err != nil { + ui.OperationError("cloning repository", err) + return + } + + repoName := destination + if repoName == "" { + repoName = git.GetRepositoryName(url) + } + + ui.OperationSuccess("Repository cloned successfully: " + repoName) + if len(profile.GitConfigs) > 0 { + ui.Success("Git configurations applied successfully") + } + }, +} + +// findArgsAfterDoubleHyphen finds arguments after a -- separator +func findArgsAfterDoubleHyphen(args []string) ([]string, bool) { + for i, arg := range args { + if arg == "--" && i < len(args)-1 { + return args[i+1:], true + } + } + return nil, false +} + +func init() { + rootCmd.AddCommand(cloneCmd) + + cloneCmd.Flags().StringP("profile", "p", "", "Profile to use for cloning") + cloneCmd.Flags().StringP("config", "c", "", "Path to config file (default is $HOME/.gclone/config.yml)") + cloneCmd.Flags().IntP("depth", "d", 0, "Create a shallow clone with the specified depth") + cloneCmd.Flags().StringP("branch", "b", "", "Clone the specified branch instead of the remote's HEAD") +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..039a209 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + "github.com/user-cube/gclone/pkg/config" + "github.com/user-cube/gclone/pkg/ui" + "gopkg.in/yaml.v3" +) + +// configCmd represents the config command +var configCmd = &cobra.Command{ + Use: "config", + Short: "Display or edit the gclone configuration", + Long: `Display or edit the gclone configuration.`, + Run: func(cmd *cobra.Command, args []string) { + configFile, _ := cmd.Flags().GetString("config") + if configFile == "" { + configFile = config.DefaultConfigFile() + } + + // Check if config file exists + if _, err := os.Stat(configFile); os.IsNotExist(err) { + ui.Warning("Configuration file does not exist at %s", configFile) + ui.Warning("Run 'gclone init' to create a default configuration") + return + } + + // Load config + cfg, err := config.LoadConfig(configFile) + if err != nil { + ui.Error("Error loading configuration: %v", err) + return + } + + // Get format + format, _ := cmd.Flags().GetString("format") + + switch format { + case "yaml": + // Marshal config to YAML + data, err := yaml.Marshal(cfg) + if err != nil { + ui.Error("Error encoding configuration: %v", err) + return + } + ui.Normal("%s\n", string(data)) + default: + // Display config in a user-friendly format + ui.Info("Configuration file: %s", configFile) + ui.Normal("\n") + + if len(cfg.Profiles) == 0 { + ui.Warning("No profiles found") + return + } + + ui.Info("Profiles:") + ui.Normal("\n") + + for name, profile := range cfg.Profiles { + ui.Section("Profile: " + ui.Highlight(name)) + ui.Normal(" Name: %s\n", profile.Name) + ui.Normal(" SSH Host: %s\n", profile.SSHHost) + + if len(profile.URLPatterns) > 0 { + ui.Normal(" URL Patterns:\n") + for _, pattern := range profile.URLPatterns { + ui.Normal(" %s\n", pattern) + } + } + + if len(profile.GitConfigs) > 0 { + ui.Normal(" Git Configs:\n") + for key, value := range profile.GitConfigs { + ui.Normal(" %s = %s\n", key, value) + } + } + ui.Normal("\n") + } + } + }, +} + +func init() { + rootCmd.AddCommand(configCmd) + configCmd.Flags().StringP("config", "c", "", "Path to config file (default is $HOME/.gclone/config.yml)") + configCmd.Flags().StringP("format", "f", "pretty", "Output format (pretty, yaml)") +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..3c612fb --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/user-cube/gclone/pkg/config" + "github.com/user-cube/gclone/pkg/ui" +) + +// initCmd represents the init command +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize the gclone configuration", + Long: `Initialize the gclone configuration file with default settings. +This will create a configuration file at ~/.gclone/config.yml with sample profiles.`, + Run: func(cmd *cobra.Command, args []string) { + configFile := config.DefaultConfigFile() + configDir := config.DefaultConfigDir() + + // Check if config file already exists + if _, err := os.Stat(configFile); err == nil { + overwrite, _ := cmd.Flags().GetBool("force") + if !overwrite { + ui.Warning("Configuration file already exists at %s", configFile) + ui.Warning("Use --force to overwrite the existing configuration") + return + } + } + + // Ensure directory exists + if err := os.MkdirAll(configDir, 0755); err != nil { + ui.Error("Error creating config directory: %v", err) + return + } + + // Get default config + cfg := config.GetDefaultConfig() + + // Save config + if err := config.SaveConfig(cfg, configFile); err != nil { + ui.Error("Error saving configuration: %v", err) + return + } + + ui.Success("Configuration initialized successfully at %s", configFile) + ui.Info("Default profiles created:") + for name, profile := range cfg.Profiles { + ui.Normal(" - %s (SSH Host: %s)\n", ui.Highlight(name), profile.SSHHost) + + if len(profile.URLPatterns) > 0 { + ui.Normal(" URL Patterns: %s\n", strings.Join(profile.URLPatterns, ", ")) + } + + ui.Normal(" Git Config: user.name=%s, user.email=%s\n", + profile.GitConfigs["user.name"], + profile.GitConfigs["user.email"]) + } + + ui.Section("Examples") + ui.Info("You can now clone repositories with a specific profile:") + ui.Normal(" gclone clone git@github.com:user/repo.git --profile=personal\n") + + ui.Info("Or let gclone automatically detect the appropriate profile based on URL patterns:") + ui.Normal(" gclone clone git@github.com:your-personal-username/repo.git\n") + ui.Info(" (Profile will be automatically detected based on configured URL patterns)") + + ui.Warning("Note: Only SSH URL format (git@github.com:user/repo.git) is supported") + + ui.Section("SSH Configuration") + ui.Info("Important: Make sure to set up your SSH configuration in ~/.ssh/config") + ui.Info(" For example, for the 'personal' profile with ssh_host 'github.com-personal':") + + sshConfigExample := `Host github.com-personal + Hostname github.com + AddKeysToAgent yes + UseKeychain yes + IdentityFile ~/.ssh/github_personal` + + ui.Normal("\n%s\n", sshConfigExample) + ui.Warning(" The Host value must match the ssh_host in your GClone profile") + + ui.Section("SSH Setup Helper") + ui.Info("Alternatively, you can use the ssh-config command to set up your SSH configuration:") + ui.Normal(" gclone ssh-config personal\n") + }, +} + +func init() { + rootCmd.AddCommand(initCmd) + initCmd.Flags().BoolP("force", "f", false, "Force overwrite existing configuration") +} diff --git a/cmd/profile.go b/cmd/profile.go new file mode 100644 index 0000000..6196a46 --- /dev/null +++ b/cmd/profile.go @@ -0,0 +1,289 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/user-cube/gclone/pkg/config" + "github.com/user-cube/gclone/pkg/ui" +) + +// profileCmd represents the profile command +var profileCmd = &cobra.Command{ + Use: "profile", + Short: "Manage profiles for gclone", + Long: `Manage profiles for gclone. You can list, add, edit, or remove profiles.`, +} + +// profileListCmd represents the profile list command +var profileListCmd = &cobra.Command{ + Use: "list", + Short: "List all profiles", + Long: `List all profiles in the gclone configuration.`, + Run: func(cmd *cobra.Command, args []string) { + configFile, _ := cmd.Flags().GetString("config") + cfg, err := config.LoadConfig(configFile) + if err != nil { + ui.Error("Error loading configuration: %v", err) + return + } + + if len(cfg.Profiles) == 0 { + ui.Warning("No profiles found. Run 'gclone init' to create default profiles.") + return + } + + ui.Section("Available profiles") + + for name, profile := range cfg.Profiles { + ui.Info("Profile: %s", ui.Highlight(name)) + ui.PrintKeyValue("Name", profile.Name) + ui.PrintKeyValue("SSH Host", profile.SSHHost) + + if len(profile.URLPatterns) > 0 { + ui.Normal(" URL Patterns:\n") + for _, pattern := range profile.URLPatterns { + ui.Normal(" %s\n", pattern) + } + } + + if len(profile.GitConfigs) > 0 { + ui.Normal(" Git Configs:\n") + for key, value := range profile.GitConfigs { + ui.Normal(" %s = %s\n", key, value) + } + } + ui.Normal("\n") + } + }, +} + +// profileAddCmd represents the profile add command +var profileAddCmd = &cobra.Command{ + Use: "add [name]", + Short: "Add a new profile", + Long: `Add a new profile to the gclone configuration. +If no name is provided, you'll be guided through an interactive profile creation process.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + configFile, _ := cmd.Flags().GetString("config") + cfg, err := config.LoadConfig(configFile) + if err != nil { + ui.Error("Error loading configuration: %v", err) + return + } + + var profileName string + + // Get profile name interactively if not provided + if len(args) == 0 { + var err error + profileName, err = ui.PromptInput("Profile name", "", func(input string) error { + if input == "" { + return fmt.Errorf("profile name cannot be empty") + } + return nil + }) + if err != nil { + ui.Error("Error getting profile name: %v", err) + return + } + } else { + profileName = args[0] + } + + // Check if profile already exists + if _, exists := cfg.Profiles[profileName]; exists { + ui.Warning("Profile '%s' already exists", profileName) + + // Confirm overwrite + confirmed, err := ui.Confirm("Do you want to overwrite it") + if err != nil || !confirmed { + ui.Warning("Operation cancelled") + return + } + } + + // Get profile details interactively or from flags + var name string + nameFlag, _ := cmd.Flags().GetString("name") + + if nameFlag != "" { + name = nameFlag + } else if len(args) == 0 { + // If in interactive mode and no name flag, prompt for display name + var err error + name, err = ui.PromptInput("Display name", profileName, nil) + if err != nil { + ui.Error("Error getting display name: %v", err) + return + } + } else { + name = profileName + } + + // Get SSH host + var sshHost string + sshHostFlag, _ := cmd.Flags().GetString("ssh-host") + + if sshHostFlag != "" { + sshHost = sshHostFlag + } else { + // Prompt for SSH host + var err error + sshHost, err = ui.PromptInput("SSH Host (e.g., github.com-personal)", "", func(input string) error { + if input == "" { + return fmt.Errorf("SSH host cannot be empty") + } + return nil + }) + if err != nil { + ui.Error("Error getting SSH host: %v", err) + return + } + } + + // Create profile + profile := config.Profile{ + Name: name, + SSHHost: sshHost, + GitConfigs: make(map[string]string), + URLPatterns: []string{}, + } + + // Get URL patterns + urlPatterns, _ := cmd.Flags().GetStringArray("url-pattern") + if len(urlPatterns) > 0 { + profile.URLPatterns = urlPatterns + } else if len(args) == 0 { + // If in interactive mode, ask if user wants to add URL patterns + addPatterns, err := ui.Confirm("Do you want to add URL patterns for automatic profile detection") + if err == nil && addPatterns { + for { + pattern, err := ui.PromptInput("URL pattern (leave empty to stop)", "", nil) + if err != nil || pattern == "" { + break + } + profile.URLPatterns = append(profile.URLPatterns, pattern) + } + } + } + + // Get Git config values + gitUsername, _ := cmd.Flags().GetString("git-username") + gitEmail, _ := cmd.Flags().GetString("git-email") + + if gitUsername != "" { + profile.GitConfigs["user.name"] = gitUsername + } else if len(args) == 0 { + // If in interactive mode, prompt for Git username + username, err := ui.PromptInput("Git username (leave empty to skip)", "", nil) + if err == nil && username != "" { + profile.GitConfigs["user.name"] = username + } + } + + if gitEmail != "" { + profile.GitConfigs["user.email"] = gitEmail + } else if len(args) == 0 { + // If in interactive mode, prompt for Git email + email, err := ui.PromptInput("Git email (leave empty to skip)", "", nil) + if err == nil && email != "" { + profile.GitConfigs["user.email"] = email + } + } + + // If in interactive mode, ask if user wants to add additional Git configurations + if len(args) == 0 { + addGitConfigs, err := ui.Confirm("Do you want to add additional Git configurations") + if err == nil && addGitConfigs { + for { + key, err := ui.PromptInput("Git config key (e.g., commit.gpgsign, leave empty to stop)", "", nil) + if err != nil || key == "" { + break + } + + value, err := ui.PromptInput("Value for "+key, "", nil) + if err != nil { + ui.Warning("Error getting config value, skipping") + continue + } + + profile.GitConfigs[key] = value + } + } + } + + // Save profile + cfg.Profiles[profileName] = profile + if err := config.SaveConfig(cfg, configFile); err != nil { + ui.Error("Error saving configuration: %v", err) + return + } + + ui.Success("Profile '%s' added successfully", profileName) + }, +} + +// profileRemoveCmd represents the profile remove command +var profileRemoveCmd = &cobra.Command{ + Use: "remove [name]", + Short: "Remove a profile", + Long: `Remove a profile from the gclone configuration.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + profileName := args[0] + + configFile, _ := cmd.Flags().GetString("config") + cfg, err := config.LoadConfig(configFile) + if err != nil { + ui.Error("Error loading configuration: %v", err) + return + } + + // Check if profile exists + if _, exists := cfg.Profiles[profileName]; !exists { + ui.Error("Profile '%s' does not exist", profileName) + return + } + + // Confirm removal + force, _ := cmd.Flags().GetBool("force") + if !force { + confirmed, err := ui.Confirm("Are you sure you want to remove profile '" + profileName + "'") + if err != nil || !confirmed { + ui.Warning("Operation cancelled") + return + } + } + + // Remove profile + delete(cfg.Profiles, profileName) + if err := config.SaveConfig(cfg, configFile); err != nil { + ui.Error("Error saving configuration: %v", err) + return + } + + ui.Success("Profile '%s' removed successfully", profileName) + }, +} + +func init() { + rootCmd.AddCommand(profileCmd) + profileCmd.AddCommand(profileListCmd) + profileCmd.AddCommand(profileAddCmd) + profileCmd.AddCommand(profileRemoveCmd) + + // Global flags for profile commands + profileCmd.PersistentFlags().StringP("config", "c", "", "Path to config file (default is $HOME/.gclone/config.yml)") + + // Flags for profile add command + profileAddCmd.Flags().StringP("name", "n", "", "Display name for the profile") + profileAddCmd.Flags().StringP("ssh-host", "s", "", "SSH host to use for this profile (e.g., github.com-personal)") + profileAddCmd.Flags().StringP("git-username", "u", "", "Git username to configure for this profile") + profileAddCmd.Flags().StringP("git-email", "e", "", "Git email to configure for this profile") + profileAddCmd.Flags().StringArrayP("url-pattern", "p", []string{}, "URL patterns to automatically match this profile (can be specified multiple times)") + + // Flags for profile remove command + profileRemoveCmd.Flags().BoolP("force", "f", false, "Force removal without confirmation") +} diff --git a/cmd/root.go b/cmd/root.go index 8377953..86c6b5d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,28 +4,24 @@ import ( "os" "github.com/spf13/cobra" + "github.com/user-cube/gclone/pkg/ui" ) // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ - Use: "$PROJECT_NAME", - Short: "A brief description of your application", - Long: `A longer description that spans multiple lines and likely contains -examples and usage of using your application. For example: - -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, - // Uncomment the following line if your bare application - // has an action associated with it: - // Run: func(cmd *cobra.Command, args []string) { }, + Use: "gclone", + Short: "A tool to manage Git repository clones with different profiles", + Long: `GClone is a command-line tool that helps you manage Git repository +clones with multiple profiles. It allows you to define different SSH hosts +and Git configurations for different Git accounts (e.g., personal, work), +and automatically applies the appropriate settings when cloning repositories.`, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - err := rootCmd.Execute() - if err != nil { + if err := rootCmd.Execute(); err != nil { + ui.Error("Error: %v", err) os.Exit(1) } } @@ -35,9 +31,5 @@ func init() { // Cobra supports persistent flags, which, if defined here, // will be global for your application. - // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.gocli-template.yaml)") - - // Cobra also supports local flags, which will only run - // when this action is called directly. - rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.gclone/config.yml)") } diff --git a/cmd/ssh_config.go b/cmd/ssh_config.go new file mode 100644 index 0000000..8759002 --- /dev/null +++ b/cmd/ssh_config.go @@ -0,0 +1,298 @@ +// Package cmd contains commands for gclone +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/user-cube/gclone/pkg/config" + "github.com/user-cube/gclone/pkg/ui" +) + +// sshConfigCmd represents the ssh-config command +var sshConfigCmd = &cobra.Command{ + Use: "ssh-config [profile]", + Short: "Generate or update SSH config for a profile", + Long: `Generate or update SSH configuration for a gclone profile. +This command helps you set up the necessary SSH configuration in ~/.gclone/ssh_config +that matches your gclone profile settings, and adds an Include directive to your +~/.ssh/config file if needed.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + configFile, _ := cmd.Flags().GetString("config") + cfg, err := config.LoadConfig(configFile) + if err != nil { + ui.Error("Error loading configuration: %v", err) + return + } + + if len(cfg.Profiles) == 0 { + ui.Warning("No profiles found. Run 'gclone init' to create default profiles.") + return + } + + var profileName string + if len(args) > 0 { + profileName = args[0] + } else if len(cfg.Profiles) == 1 { + // If only one profile exists, use it + for name := range cfg.Profiles { + profileName = name + } + } else { + // If multiple profiles exist, prompt for selection + profileNames := make([]string, 0, len(cfg.Profiles)) + for name := range cfg.Profiles { + profileNames = append(profileNames, name) + } + + selectedProfile, err := ui.SelectFromList("Select profile to configure SSH for", profileNames) + if err != nil { + ui.Error("Error selecting profile: %v", err) + return + } + profileName = selectedProfile + } + + // Check if profile exists + profile, ok := cfg.Profiles[profileName] + if !ok { + ui.Error("Profile '%s' not found", profileName) + return + } + + // Generate SSH config + sshHost := profile.SSHHost + if sshHost == "" { + ui.Error("Profile '%s' does not have an SSH host configured", profileName) + return + } + + identityFile, _ := cmd.Flags().GetString("identity-file") + if identityFile == "" { + // Suggest a default identity file name + defaultIdentityFile := fmt.Sprintf("~/.ssh/%s", strings.Replace(sshHost, "github.com-", "github_", 1)) + var err error + identityFile, err = ui.PromptInput("SSH identity file path", defaultIdentityFile, nil) + if err != nil { + ui.Error("Error getting identity file: %v", err) + return + } + } + + sshConfig := fmt.Sprintf(`# %s profile (added by gclone) +Host %s + Hostname github.com + AddKeysToAgent yes + UseKeychain yes + IdentityFile %s +`, profile.Name, sshHost, identityFile) + + // Get SSH config path + homeDir, err := os.UserHomeDir() + if err != nil { + ui.Error("Error getting home directory: %v", err) + return + } + + // Create the gclone SSH config file + gcloneDir := filepath.Join(homeDir, ".gclone") + gcloneSshConfigPath := filepath.Join(gcloneDir, "ssh_config") + mainSshConfigPath := filepath.Join(homeDir, ".ssh", "config") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if dryRun { + ui.Section("SSH Configuration (Dry Run)") + ui.Info("The following configuration would be added to %s:", gcloneSshConfigPath) + fmt.Println() + ui.Normal("%s\n", sshConfig) + + includeDirective := "Include ~/.gclone/ssh_config" + ui.Info("And the following include directive would be added to %s (if not already present):", mainSshConfigPath) + fmt.Println() + ui.Normal("%s\n", includeDirective) + return + } + + // Ensure gclone directory exists + if err := os.MkdirAll(gcloneDir, 0755); err != nil { + ui.Error("Error creating gclone directory: %v", err) + return + } + + // Check if gclone SSH config file exists + gcloneSshConfigExists := false + var existingContent string + + if _, err := os.Stat(gcloneSshConfigPath); err == nil { + gcloneSshConfigExists = true + + // Read existing gclone SSH config + content, err := os.ReadFile(gcloneSshConfigPath) + if err != nil { + ui.Error("Error reading gclone SSH config: %v", err) + return + } + existingContent = string(content) + + // Check if host already exists + hostPattern := fmt.Sprintf("Host %s", sshHost) + if strings.Contains(existingContent, hostPattern) { + ui.Warning("SSH configuration for '%s' already exists in %s", sshHost, gcloneSshConfigPath) + + confirmed, err := ui.Confirm("Do you want to update it") + if err != nil || !confirmed { + ui.Warning("Operation cancelled") + return + } + + // Remove existing configuration for this host + lines := strings.Split(existingContent, "\n") + newLines := []string{} + skip := false + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + if strings.HasPrefix(trimmedLine, hostPattern) { + skip = true + continue + } else if skip && strings.HasPrefix(trimmedLine, "Host ") { + skip = false + } + + if !skip { + newLines = append(newLines, line) + } + } + + // Update the content without the host + existingContent = strings.Join(newLines, "\n") + } + } + + // Write or update the gclone SSH config file + var newContent string + if gcloneSshConfigExists { + // Add a newline if the file doesn't end with one + if existingContent != "" && !strings.HasSuffix(existingContent, "\n") { + newContent = existingContent + "\n" + sshConfig + } else { + newContent = existingContent + sshConfig + } + } else { + newContent = sshConfig + } + + // Write the gclone SSH config file + if err := os.WriteFile(gcloneSshConfigPath, []byte(newContent), 0644); err != nil { + ui.Error("Error writing gclone SSH config: %v", err) + return + } + + // Now ensure the main SSH config includes our gclone SSH config + ensureIncludeDirective(mainSshConfigPath, "~/.gclone/ssh_config") + + action := "created" + if gcloneSshConfigExists { + action = "updated" + } + + ui.Success("SSH configuration %s at %s", action, gcloneSshConfigPath) + ui.Info("Configuration for '%s' added:", sshHost) + ui.Normal("%s\n", sshConfig) + + // Remind about creating the SSH key if it doesn't exist + identityFileExpanded := strings.Replace(identityFile, "~/", homeDir+"/", 1) + if _, err := os.Stat(identityFileExpanded); os.IsNotExist(err) { + ui.Warning("SSH key %s does not exist yet", identityFile) + ui.Info("You can create it with:") + ui.Normal(" ssh-keygen -t ed25519 -f %s -C \"your_email@example.com\"\n", identityFile) + } + }, +} + +// ensureIncludeDirective ensures that the specified SSH config file includes the given path +func ensureIncludeDirective(sshConfigPath, includePath string) { + // Ensure SSH directory exists + sshDir := filepath.Dir(sshConfigPath) + if err := os.MkdirAll(sshDir, 0700); err != nil { + ui.Error("Error creating SSH directory: %v", err) + return + } + + includeDirective := fmt.Sprintf("Include %s", includePath) + + // Check if SSH config file exists + if _, err := os.Stat(sshConfigPath); err == nil { + // Read existing SSH config + content, err := os.ReadFile(sshConfigPath) + if err != nil { + ui.Error("Error reading SSH config: %v", err) + return + } + + // Check if include directive already exists + if strings.Contains(string(content), includeDirective) { + // Include directive already exists, nothing to do + return + } + + // Add include directive at the top of the file with other includes + lines := strings.Split(string(content), "\n") + newLines := []string{} + includeAdded := false + + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + // Add include directive after any existing include lines + // but before the first non-include, non-comment line + if strings.HasPrefix(trimmedLine, "Include ") { + newLines = append(newLines, line) + // If this is the last include line, add our include directive + if i+1 < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i+1]), "Include ") { + newLines = append(newLines, includeDirective) + includeAdded = true + } + } else if !includeAdded && i > 0 && !strings.HasPrefix(trimmedLine, "#") && trimmedLine != "" { + // First non-include, non-comment line and we haven't added our include yet + newLines = append(newLines, includeDirective) + newLines = append(newLines, line) + includeAdded = true + } else { + newLines = append(newLines, line) + } + } + + // If we still haven't added the include directive (e.g., file had no includes), + // add it at the beginning + if !includeAdded { + newLines = append([]string{includeDirective}, newLines...) + } + + // Write back the updated content + if err := os.WriteFile(sshConfigPath, []byte(strings.Join(newLines, "\n")), 0644); err != nil { + ui.Error("Error updating SSH config: %v", err) + return + } + + ui.Success("Added include directive to %s", sshConfigPath) + } else { + // Create new SSH config file with include directive + if err := os.WriteFile(sshConfigPath, []byte(includeDirective+"\n"), 0644); err != nil { + ui.Error("Error creating SSH config: %v", err) + return + } + + ui.Success("Created SSH config file at %s with include directive", sshConfigPath) + } +} + +func init() { + rootCmd.AddCommand(sshConfigCmd) + sshConfigCmd.Flags().StringP("config", "c", "", "Path to config file (default is $HOME/.gclone/config.yml)") + sshConfigCmd.Flags().StringP("identity-file", "i", "", "Path to SSH identity file (default is ~/.ssh/github_)") + sshConfigCmd.Flags().BoolP("dry-run", "d", false, "Print the configuration without writing to file") +} diff --git a/cmd/version.go b/cmd/version.go index 05b22ec..0280812 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,7 +4,7 @@ import ( "runtime" "github.com/spf13/cobra" - "github.com/user-cube/$PROJECT_NAME/pkg/ui" + "github.com/user-cube/gclone/pkg/ui" ) // Version information @@ -17,14 +17,14 @@ var ( // versionCmd represents the version command var versionCmd = &cobra.Command{ Use: "version", - Short: "Print the version information of $PROJECT_NAME", - Long: `Display the version, build date, and git commit of your $PROJECT_NAME installation. + Short: "Print the version information of GClone", + Long: `Display the version, build date, and git commit of your GClone installation. Examples: # Show version information - $PROJECT_NAME version`, + gclone version`, Run: func(cmd *cobra.Command, args []string) { - ui.PrintInfo("$PROJECT_NAME", Version) + ui.PrintInfo("GClone", Version) ui.PrintInfo("Git Commit", GitCommit) ui.PrintInfo("Built", BuildDate) ui.PrintInfo("Platform", runtime.GOOS+"/"+runtime.GOARCH) diff --git a/demo/demo_script.sh b/demo/demo_script.sh index 2b76c4d..7ac12cb 100755 --- a/demo/demo_script.sh +++ b/demo/demo_script.sh @@ -1,8 +1,8 @@ #!/bin/bash -echo "print command" -# execute command +echo "gclone clone git@github.com:user-cube/gclone.git" +gclone clone git@github.com:user-cube/gclone.git sleep 2 clear -echo "print command" -# execute command +echo "gclone config" +gclone config sleep 2 \ No newline at end of file diff --git a/demo/gclone-demo.gif b/demo/gclone-demo.gif new file mode 100644 index 0000000..2a60924 Binary files /dev/null and b/demo/gclone-demo.gif differ diff --git a/demo/make_demo.sh b/demo/make_demo.sh index eb81909..7cef9fa 100755 --- a/demo/make_demo.sh +++ b/demo/make_demo.sh @@ -1,11 +1,11 @@ #!/bin/bash # Run demo_script and record -asciinema rec $PROJECT_NAME.cast -c "./demo_script.sh" --overwrite +asciinema rec gclone.cast -c "./demo_script.sh" --overwrite # Convert to GIF -asciicast2gif $PROJECT_NAME.cast $PROJECT_NAME.gif +asciicast2gif gclone.cast gclone.gif # Optimize GIF -gifsicle -O3 --colors 256 $PROJECT_NAME.gif -o $PROJECT_NAME-demo.gif +gifsicle -O3 --colors 256 gclone.gif -o gclone-demo.gif -echo "✅ Done! Your demo GIF is ready as $PROJECT_NAME-demo.gif" \ No newline at end of file +echo "✅ Done! Your demo GIF is ready as gclone-demo.gif" \ No newline at end of file diff --git a/go.mod b/go.mod index 7d65d65..5ef80af 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/user-cube/gocli-template +module github.com/user-cube/gclone go 1.23.4 @@ -6,6 +6,7 @@ require ( github.com/fatih/color v1.18.0 github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.9.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index dc62b15..e35ff9b 100644 --- a/go.sum +++ b/go.sum @@ -26,5 +26,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/gorelease.yml b/goreleaser.yml similarity index 71% rename from gorelease.yml rename to goreleaser.yml index 41a3d6f..f99898d 100644 --- a/gorelease.yml +++ b/goreleaser.yml @@ -4,7 +4,7 @@ version: 2 -project_name: $PROJECT_NAME +project_name: gclone before: hooks: @@ -14,7 +14,7 @@ builds: - env: - CGO_ENABLED=0 ldflags: - - -s -w -X github.com/user-cube/$PROJECT_NAME/cmd.Version={{ .Env.VERSION }} -X github.com/user-cube/$PROJECT_NAME/cmd.GitCommit={{ .Env.GIT_COMMIT }} -X github.com/user-cube/$PROJECT_NAME/cmd.BuildDate={{ .Env.BUILD_DATE }} + - -s -w -X github.com/user-cube/gclone/cmd.Version={{ .Env.VERSION }} -X github.com/user-cube/gclone/cmd.GitCommit={{ .Env.GIT_COMMIT }} -X github.com/user-cube/gclone/cmd.BuildDate={{ .Env.BUILD_DATE }} goos: - linux - darwin diff --git a/main.go b/main.go index 0462afe..ec939ac 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,6 @@ package main -import "github.com/user-cube/$PROJECT_NAME/cmd" +import "github.com/user-cube/gclone/cmd" func main() { cmd.Execute() diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..2328bef --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,126 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config represents the main configuration structure +type Config struct { + Profiles map[string]Profile `yaml:"profiles"` +} + +// Profile represents a single profile configuration +type Profile struct { + Name string `yaml:"name"` + SSHHost string `yaml:"ssh_host"` + URLPatterns []string `yaml:"url_patterns"` + GitConfigs map[string]string `yaml:"git_configs"` +} + +// DefaultConfigDir returns the default config directory path +func DefaultConfigDir() string { + home, err := os.UserHomeDir() + if err != nil { + return ".gclone" + } + return filepath.Join(home, ".gclone") +} + +// DefaultConfigFile returns the default config file path +func DefaultConfigFile() string { + return filepath.Join(DefaultConfigDir(), "config.yml") +} + +// LoadConfig loads the configuration from the specified file +func LoadConfig(configFile string) (*Config, error) { + if configFile == "" { + configFile = DefaultConfigFile() + } + + data, err := os.ReadFile(configFile) + if err != nil { + if os.IsNotExist(err) { + return &Config{Profiles: make(map[string]Profile)}, nil + } + return nil, fmt.Errorf("error reading config file: %w", err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("error parsing config file: %w", err) + } + + // Initialize maps if they're nil + if config.Profiles == nil { + config.Profiles = make(map[string]Profile) + } + + for name, profile := range config.Profiles { + if profile.GitConfigs == nil { + profile.GitConfigs = make(map[string]string) + config.Profiles[name] = profile + } + } + + return &config, nil +} + +// SaveConfig saves the configuration to the specified file +func SaveConfig(config *Config, configFile string) error { + if configFile == "" { + configFile = DefaultConfigFile() + } + + // Ensure the directory exists + configDir := filepath.Dir(configFile) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("error creating config directory: %w", err) + } + + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("error encoding config: %w", err) + } + + if err := os.WriteFile(configFile, data, 0644); err != nil { + return fmt.Errorf("error writing config file: %w", err) + } + + return nil +} + +// GetDefaultConfig returns a default configuration +func GetDefaultConfig() *Config { + return &Config{ + Profiles: map[string]Profile{ + "personal": { + Name: "Personal", + SSHHost: "github.com-personal", + URLPatterns: []string{ + "github.com/your-personal-username", + "github.com:your-personal-username", + }, + GitConfigs: map[string]string{ + "user.name": "Your Name", + "user.email": "your.email@example.com", + }, + }, + "work": { + Name: "Work", + SSHHost: "github.com-work", + URLPatterns: []string{ + "github.com/your-work-organization", + "github.com:your-work-organization", + }, + GitConfigs: map[string]string{ + "user.name": "Your Work Name", + "user.email": "your.work.email@example.com", + }, + }, + }, + } +} diff --git a/pkg/git/git.go b/pkg/git/git.go new file mode 100644 index 0000000..932aa3f --- /dev/null +++ b/pkg/git/git.go @@ -0,0 +1,130 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/user-cube/gclone/pkg/config" + "github.com/user-cube/gclone/pkg/ui" +) + +// TransformGitURL transforms a git URL to use the specified SSH host +func TransformGitURL(url string, profile *config.Profile) (string, error) { + if profile == nil || profile.SSHHost == "" { + return url, nil + } + + // Handle SSH URL format (git@github.com:user/repo.git) + sshRegex := regexp.MustCompile(`^git@([^:]+):(.+)$`) + if matches := sshRegex.FindStringSubmatch(url); len(matches) == 3 { + // We don't need to use originalHost, just get the path part + path := matches[2] + + // Replace the host with the profile's SSH host + return fmt.Sprintf("git@%s:%s", profile.SSHHost, path), nil + } + + return url, fmt.Errorf("unsupported git URL format: %s (only SSH URLs are supported)", url) +} + +// CloneRepository clones a repository using the specified profile +func CloneRepository(url, destination string, profile *config.Profile, extraArgs []string) error { + if profile != nil { + var err error + url, err = TransformGitURL(url, profile) + if err != nil { + return err + } + } + + // Prepare the git clone command + args := []string{"clone", url} + + // Add destination if provided + if destination != "" { + args = append(args, destination) + } else { + // Extract repo name from URL for better error messages + parts := strings.Split(url, "/") + if len(parts) > 0 { + repoName := strings.TrimSuffix(parts[len(parts)-1], ".git") + destination = repoName + } + } + + // Add any extra arguments + if len(extraArgs) > 0 { + args = append(args, extraArgs...) + } + + // Execute the git clone command + ui.Info("Running git %s", strings.Join(args, " ")) + cmd := exec.Command("git", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + + // Apply Git configurations if a profile is specified + if profile != nil && len(profile.GitConfigs) > 0 { + repoPath := destination + if repoPath == "" { + // Extract repository name from URL + parts := strings.Split(url, "/") + repoName := strings.TrimSuffix(parts[len(parts)-1], ".git") + repoPath = repoName + } + + // Apply Git configurations + if err := ApplyGitConfigs(repoPath, profile.GitConfigs); err != nil { + return fmt.Errorf("failed to apply git configs: %w", err) + } + } + + return nil +} + +// ApplyGitConfigs applies Git configurations to a repository +func ApplyGitConfigs(repoPath string, configs map[string]string) error { + // Ensure the path exists + if _, err := os.Stat(repoPath); os.IsNotExist(err) { + return fmt.Errorf("repository path does not exist: %s", repoPath) + } + + // Apply each configuration + for key, value := range configs { + ui.Info("Setting git config %s=%s", key, value) + cmd := exec.Command("git", "config", "--local", key, value) + cmd.Dir = repoPath + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to set git config %s=%s: %w", key, value, err) + } + } + + return nil +} + +// GetRepositoryName extracts the repository name from a Git URL +func GetRepositoryName(url string) string { + // Remove .git suffix if present + url = strings.TrimSuffix(url, ".git") + + // For SSH URLs like git@github.com:user/repo + if strings.Contains(url, ":") { + parts := strings.Split(url, ":") + if len(parts) > 1 { + subParts := strings.Split(parts[1], "/") + if len(subParts) > 0 { + return subParts[len(subParts)-1] + } + } + } + + return "" +} diff --git a/pkg/git/profile_detection.go b/pkg/git/profile_detection.go new file mode 100644 index 0000000..cc534ee --- /dev/null +++ b/pkg/git/profile_detection.go @@ -0,0 +1,39 @@ +package git + +import ( + "strings" + + "github.com/user-cube/gclone/pkg/config" +) + +// DetectProfileForURL determines which profile to use based on the repository URL +func DetectProfileForURL(url string, profiles map[string]config.Profile) (string, bool) { + // Normalize the URL to handle SSH format + normalizedURL := NormalizeURL(url) + + // Check each profile for matching URL patterns + for name, profile := range profiles { + for _, pattern := range profile.URLPatterns { + if strings.Contains(normalizedURL, pattern) { + return name, true + } + } + } + + return "", false +} + +// NormalizeURL converts SSH URLs to a common format for pattern matching +func NormalizeURL(url string) string { + // Handle SSH URL format (git@github.com:user/repo.git) + if strings.HasPrefix(url, "git@") { + // Convert git@github.com:user/repo.git to github.com:user/repo.git + parts := strings.SplitN(url, "@", 2) + if len(parts) == 2 { + return parts[1] + } + } + + // Return as is if we can't normalize + return url +} diff --git a/pkg/ui/messages.go b/pkg/ui/messages.go index f8167fa..e283d84 100644 --- a/pkg/ui/messages.go +++ b/pkg/ui/messages.go @@ -2,64 +2,10 @@ package ui import ( "fmt" - "os" - - "github.com/fatih/color" ) -// PrintError prints a formatted error message and exits if exitOnError is true -// If err is nil, only the message is displayed -func PrintError(msg string, err error, exitOnError bool) { - colors := NewColors() - if err != nil { - fmt.Printf("%s %s: %v\n", colors.Red("✗"), msg, err) - } else { - fmt.Printf("%s %s\n", colors.Red("✗"), msg) - } - if exitOnError { - os.Exit(1) - } -} - -// PrintSuccess prints a formatted success message -func PrintSuccess(msg string, details ...string) { - colors := NewColors() - fmt.Printf("%s %s", colors.Green("✓"), msg) - - for _, detail := range details { - fmt.Printf(" %s", colors.Cyan(detail)) - } - fmt.Println() -} - -// PrintWarning prints a formatted warning message -func PrintWarning(msg string, details ...string) { - colors := NewColors() - fmt.Printf("%s %s", colors.Yellow("!"), msg) - - for _, detail := range details { - fmt.Printf(" %s", colors.Cyan(detail)) - } - fmt.Println() -} - // PrintInfo prints a formatted information label and value func PrintInfo(label string, value string) { colors := NewColors() fmt.Printf("%s: %s\n", colors.Bold(label), value) } - -// PrintNote prints a formatted note message with an info icon -func PrintNote(msg string, details ...string) { - colors := NewColors() - // Using blue color with info icon for notes - blue := color.New(color.FgBlue, color.Bold).SprintFunc() - fmt.Printf("%s %s", blue("ℹ"), blue("Note:")) - - fmt.Printf(" %s", msg) - - for _, detail := range details { - fmt.Printf(" %s", colors.Cyan(detail)) - } - fmt.Println() -} diff --git a/pkg/ui/operations.go b/pkg/ui/operations.go new file mode 100644 index 0000000..5a84e4e --- /dev/null +++ b/pkg/ui/operations.go @@ -0,0 +1,25 @@ +package ui + +// OperationInfo displays information about an operation +func OperationInfo(operation string, profile string, details map[string]string) { + Section(operation + " with profile: " + Highlight(profile)) + + for key, value := range details { + PrintKeyValue(key, value) + } + + Normal("\n") +} + +// OperationSuccess displays a success message for an operation +func OperationSuccess(message string, details ...string) { + Success("%s\n", message) + for _, detail := range details { + Normal(" %s\n", detail) + } +} + +// OperationError displays an error message for an operation +func OperationError(operation string, err error) { + Error("Error %s: %v\n", operation, err) +} diff --git a/pkg/ui/table.go b/pkg/ui/table.go new file mode 100644 index 0000000..31588d6 --- /dev/null +++ b/pkg/ui/table.go @@ -0,0 +1,90 @@ +package ui + +import ( + "strings" +) + +// TableColumn defines a column in a table +type TableColumn struct { + Header string + Width int +} + +// Table represents a simple text-based table +type Table struct { + Columns []TableColumn + Rows [][]string + HasHeader bool +} + +// NewTable creates a new table with column headers +func NewTable(columns []TableColumn) *Table { + return &Table{ + Columns: columns, + Rows: [][]string{}, + HasHeader: true, + } +} + +// AddRow adds a row to the table +func (t *Table) AddRow(values ...string) { + // Ensure the values match the number of columns + if len(values) > len(t.Columns) { + values = values[:len(t.Columns)] + } else if len(values) < len(t.Columns) { + // Fill with empty strings + for i := len(values); i < len(t.Columns); i++ { + values = append(values, "") + } + } + t.Rows = append(t.Rows, values) +} + +// Print prints the table +func (t *Table) Print() { + if len(t.Columns) == 0 { + return + } + + // Print header if it exists + if t.HasHeader { + headerString := "" + for i, col := range t.Columns { + if i > 0 { + headerString += " | " + } + headerString += padString(col.Header, col.Width) + } + Normal("%s\n", headerString) + + // Print separator + separator := "" + for i, col := range t.Columns { + if i > 0 { + separator += "-+-" + } + separator += strings.Repeat("-", col.Width) + } + Normal("%s\n", separator) + } + + // Print rows + for _, row := range t.Rows { + rowString := "" + for i, val := range row { + if i > 0 { + rowString += " | " + } + rowString += padString(val, t.Columns[i].Width) + } + Normal("%s\n", rowString) + } +} + +// padString pads the string to fill the specified width +func padString(s string, width int) string { + if len(s) > width { + return s[:width-3] + "..." + } + return s + strings.Repeat(" ", width-len(s)) +}