Add RemoteURL field to VCSBackend and implement for all backends

Co-authored-by: Songmu <177122+Songmu@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-02-13 17:35:14 +00:00
parent 12f129c2f0
commit 9515744ee5
3 changed files with 186 additions and 55 deletions

View file

@ -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
}

View file

@ -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
}

145
vcs.go
View file

@ -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{