diff --git a/cmd_migrate.go b/cmd_migrate.go index 846dd43..ecb4f41 100644 --- a/cmd_migrate.go +++ b/cmd_migrate.go @@ -3,9 +3,7 @@ package main import ( "fmt" "os" - "os/exec" "path/filepath" - "strings" "github.com/urfave/cli/v2" ) @@ -41,15 +39,13 @@ func doMigrate(c *cli.Context) error { return fmt.Errorf("failed to detect VCS backend in %q", absDir) } - // Get remote URL (currently only git is supported) - var remoteURL string - if vcsBackend == GitBackend { - remoteURL, err = getGitRemoteURL(absDir) - if err != nil { - return fmt.Errorf("failed to get remote URL: %w", err) - } - } else { - return fmt.Errorf("only git repositories are currently supported") + // Get remote URL + if vcsBackend.RemoteURL == nil { + return fmt.Errorf("migrate is not supported for this VCS backend") + } + remoteURL, err := vcsBackend.RemoteURL(absDir) + if err != nil { + return fmt.Errorf("failed to get remote URL: %w", err) } // Parse the remote URL @@ -109,47 +105,3 @@ func doMigrate(c *cli.Context) error { fmt.Fprintln(w, destPath) return nil } - -// getGitRemoteURL retrieves the remote URL from a git repository -// It tries 'origin' first, then falls back to the first remote -func getGitRemoteURL(repoDir string) (string, error) { - // Try to get the URL of 'origin' remote - cmd := exec.Command("git", "remote", "get-url", "origin") - cmd.Dir = repoDir - output, err := cmd.Output() - if err == nil { - url := strings.TrimSpace(string(output)) - if url != "" { - return url, nil - } - } - - // Fall back to the first remote - cmd = exec.Command("git", "remote") - cmd.Dir = repoDir - output, err = cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to list remotes: %w", err) - } - - remotes := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(remotes) == 0 || remotes[0] == "" { - return "", fmt.Errorf("no remotes found") - } - - // Get URL of the first remote - firstRemote := remotes[0] - cmd = exec.Command("git", "remote", "get-url", firstRemote) - cmd.Dir = repoDir - output, err = cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to get URL of remote %q: %w", firstRemote, err) - } - - url := strings.TrimSpace(string(output)) - if url == "" { - return "", fmt.Errorf("remote %q has no URL", firstRemote) - } - - return url, nil -} diff --git a/cmd_migrate_test.go b/cmd_migrate_test.go index eb30fbb..0bd38d6 100644 --- a/cmd_migrate_test.go +++ b/cmd_migrate_test.go @@ -135,4 +135,38 @@ func TestMigrateEdgeCases(t *testing.T) { t.Error("should fail when dest exists") } }) + + t.Run("unsupported_vcs", func(t *testing.T) { + // Create a CVS repository structure to test unsupported VCS + srcdir := filepath.Join(tmpdir, "src6", "cvs-repo") + cvsDir := filepath.Join(srcdir, "CVS") + os.MkdirAll(cvsDir, 0755) + + // Create a minimal CVS/Repository file + repoFile := filepath.Join(cvsDir, "Repository") + os.WriteFile(repoFile, []byte("test-repo\n"), 0644) + + a := newApp() + e := a.Run([]string{"ghq", "migrate", "-y", srcdir}) + if e == nil { + t.Error("should fail for unsupported VCS (CVS)") + } + // Check that the error message mentions unsupported VCS + if e != nil && !contains(e.Error(), "not supported") { + t.Errorf("expected 'not supported' error, got: %v", e) + } + }) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false } diff --git a/vcs.go b/vcs.go index dd2a1a2..79acbf8 100644 --- a/vcs.go +++ b/vcs.go @@ -38,6 +38,9 @@ type VCSBackend struct { Init func(dir string) error // Returns VCS specific files Contents []string + // Returns the remote URL of the repository at the given directory. + // If nil, the VCS backend does not support retrieving remote URLs. + RemoteURL func(dir string) (string, error) } type vcsGetOption struct { @@ -47,6 +50,51 @@ type vcsGetOption struct { branch, partial string } +// getGitRemoteURL retrieves the remote URL from a git repository. +// It tries 'origin' first, then falls back to the first remote. +func getGitRemoteURL(dir string) (string, error) { + // Try 'origin' first + originCmd := exec.Command("git", "remote", "get-url", "origin") + originCmd.Dir = dir + originOut, originErr := originCmd.Output() + if originErr == nil { + originURL := strings.TrimSpace(string(originOut)) + if originURL != "" { + return originURL, nil + } + } + + // List all remotes + listCmd := exec.Command("git", "remote") + listCmd.Dir = dir + listOut, listErr := listCmd.Output() + if listErr != nil { + return "", fmt.Errorf("failed to list remotes: %w", listErr) + } + + allRemotes := strings.Split(strings.TrimSpace(string(listOut)), "\n") + if len(allRemotes) == 0 || allRemotes[0] == "" { + return "", fmt.Errorf("no remotes found") + } + + // Get first remote URL + first := allRemotes[0] + urlCmd := exec.Command("git", "remote", "get-url", first) + urlCmd.Dir = dir + urlOut, urlErr := urlCmd.Output() + if urlErr != nil { + return "", fmt.Errorf("failed to get URL of remote %q: %w", first, urlErr) + } + + finalURL := strings.TrimSpace(string(urlOut)) + if finalURL == "" { + return "", fmt.Errorf("remote %q has no URL", first) + } + + return finalURL, nil +} + + // GitBackend is the VCSBackend of git var GitBackend = &VCSBackend{ // support submodules? @@ -111,6 +159,9 @@ var GitBackend = &VCSBackend{ return cmdutil.RunInDir(dir, "git", args...) }, Contents: []string{".git"}, + RemoteURL: func(dir string) (string, error) { + return getGitRemoteURL(dir) + }, } /* @@ -184,6 +235,19 @@ var SubversionBackend = &VCSBackend{ return runInDir(vg.silent)(vg.dir, "svn", "update") }, Contents: []string{".svn"}, + RemoteURL: func(dir string) (string, error) { + cmd := exec.Command("svn", "info", "--show-item", "repos-root-url") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get repository root URL: %w", err) + } + url := strings.TrimSpace(string(output)) + if url == "" { + return "", fmt.Errorf("repository root URL is empty") + } + return url, nil + }, } var svnLastRevReg = regexp.MustCompile(`(?m)^Last Changed Rev: (\d+)$`) @@ -251,6 +315,10 @@ var GitsvnBackend = &VCSBackend{ return runInDir(vg.silent)(vg.dir, "git", "svn", "rebase") }, Contents: []string{".git/svn"}, + RemoteURL: func(dir string) (string, error) { + // git-svn repos are git repos, use git remote logic + return getGitRemoteURL(dir) + }, } // MercurialBackend is the VCSBackend for mercurial @@ -277,6 +345,19 @@ var MercurialBackend = &VCSBackend{ return cmdutil.RunInDir(dir, "hg", "init") }, Contents: []string{".hg"}, + RemoteURL: func(dir string) (string, error) { + cmd := exec.Command("hg", "paths", "default") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get default path: %w", err) + } + url := strings.TrimSpace(string(output)) + if url == "" { + return "", fmt.Errorf("default path is empty") + } + return url, nil + }, } // DarcsBackend is the VCSBackend for darcs @@ -307,6 +388,27 @@ var DarcsBackend = &VCSBackend{ return cmdutil.RunInDir(dir, "darcs", "init") }, Contents: []string{"_darcs"}, + RemoteURL: func(dir string) (string, error) { + cmd := exec.Command("darcs", "show", "repo") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to show repo: %w", err) + } + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "Default Remote:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + url := strings.TrimSpace(parts[1]) + if url != "" { + return url, nil + } + } + } + } + return "", fmt.Errorf("no default remote found") + }, } // PijulBackend is the VCSBackend for pijul @@ -333,6 +435,23 @@ var PijulBackend = &VCSBackend{ return cmdutil.RunInDir(dir, "pijul", "init") }, Contents: []string{".pijul"}, + RemoteURL: func(dir string) (string, error) { + cmd := exec.Command("pijul", "remote") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to list remotes: %w", err) + } + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + // First non-empty line is the first remote + return trimmed, nil + } + } + return "", fmt.Errorf("no remotes found") + }, } var cvsDummyBackend = &VCSBackend{ @@ -372,6 +491,19 @@ var FossilBackend = &VCSBackend{ return cmdutil.RunInDir(dir, "fossil", "open", fossilRepoName) }, Contents: []string{".fslckout", "_FOSSIL_"}, + RemoteURL: func(dir string) (string, error) { + cmd := exec.Command("fossil", "remote-url") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get remote URL: %w", err) + } + url := strings.TrimSpace(string(output)) + if url == "" || url == "off" { + return "", fmt.Errorf("no remote URL configured") + } + return url, nil + }, } // BazaarBackend is the VCSBackend for bazaar @@ -396,6 +528,19 @@ var BazaarBackend = &VCSBackend{ return cmdutil.RunInDir(dir, "bzr", "init") }, Contents: []string{".bzr"}, + RemoteURL: func(dir string) (string, error) { + cmd := exec.Command("bzr", "config", "parent_location") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get parent location: %w", err) + } + url := strings.TrimSpace(string(output)) + if url == "" { + return "", fmt.Errorf("parent location is empty") + } + return url, nil + }, } var vcsRegistry = map[string]*VCSBackend{