package main import ( "bytes" "errors" "fmt" "io" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "github.com/x-motemen/ghq/cmdutil" ) func run(silent bool) func(command string, args ...string) error { if silent { return cmdutil.RunSilently } return cmdutil.Run } func runInDir(silent bool) func(dir, command string, args ...string) error { if silent { return cmdutil.RunInDirSilently } return cmdutil.RunInDir } // A VCSBackend represents a VCS backend. type VCSBackend struct { // Clones a remote repository to local path. Clone func(*vcsGetOption) error // Updates a cloned local repository. Update func(*vcsGetOption) error 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 { url *url.URL dir string recursive, shallow, silent, bare bool 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? Clone: func(vg *vcsGetOption) error { dir, _ := filepath.Split(vg.dir) err := os.MkdirAll(dir, 0755) if err != nil { return err } args := []string{"clone"} if vg.shallow { args = append(args, "--depth", "1") } if vg.branch != "" { args = append(args, "--branch", vg.branch, "--single-branch") } if vg.recursive { args = append(args, "--recursive") } if vg.bare { args = append(args, "--bare") } if vg.partial == "blobless" { args = append(args, "--filter=blob:none") } else if vg.partial == "treeless" { args = append(args, "--filter=tree:0") } args = append(args, vg.url.String(), vg.dir) return run(vg.silent)("git", args...) }, Update: func(vg *vcsGetOption) error { if _, err := os.Stat(filepath.Join(vg.dir, ".git/svn")); err == nil { return GitsvnBackend.Update(vg) } if vg.bare { return runInDir(true)(vg.dir, "git", "fetch", vg.url.String(), "*:*") } err := runInDir(true)(vg.dir, "git", "rev-parse", "@{upstream}") if err != nil { err := runInDir(vg.silent)(vg.dir, "git", "fetch") if err != nil { return err } return nil } err = runInDir(vg.silent)(vg.dir, "git", "pull", "--ff-only") if err != nil { return err } if vg.recursive { return runInDir(vg.silent)(vg.dir, "git", "submodule", "update", "--init", "--recursive") } return nil }, Init: func(dir string) error { args := []string{"init"} if strings.HasSuffix(dir, ".git") { args = append(args, "--bare") } return cmdutil.RunInDir(dir, "git", args...) }, Contents: []string{".git"}, RemoteURL: func(dir string) (string, error) { return getGitRemoteURL(dir) }, } /* If the svn target is under standard svn directory structure, "ghq" canonicalizes the checkout path. For example, all following targets are checked-out into `$(ghq root)/svn.example.com/proj/repo`. - svn.example.com/proj/repo - svn.example.com/proj/repo/trunk - svn.example.com/proj/repo/branches/featureN - svn.example.com/proj/repo/tags/v1.0.1 Addition, when the svn target may be project root, "ghq" tries to checkout "/trunk". This checkout rule is also applied when using "git-svn". */ const trunk = "/trunk" var svnReg = regexp.MustCompile(`/(?:tags|branches)/[^/]+$`) func replaceOnce(reg *regexp.Regexp, str, replace string) string { replaced := false return reg.ReplaceAllStringFunc(str, func(match string) string { if replaced { return match } replaced = true return reg.ReplaceAllString(match, replace) }) } func svnBase(p string) string { if before, ok := strings.CutSuffix(p, trunk); ok { return before } return replaceOnce(svnReg, p, "") } // SubversionBackend is the VCSBackend for subversion var SubversionBackend = &VCSBackend{ Clone: func(vg *vcsGetOption) error { vg.dir = svnBase(vg.dir) dir, _ := filepath.Split(vg.dir) err := os.MkdirAll(dir, 0755) if err != nil { return err } args := []string{"checkout"} if vg.shallow { args = append(args, "--depth", "immediates") } remote := vg.url if vg.branch != "" { copied := *vg.url remote = &copied remote.Path = svnBase(remote.Path) remote.Path += "/branches/" + url.PathEscape(vg.branch) } else if !strings.HasSuffix(remote.Path, trunk) { copied := *vg.url copied.Path += trunk if err := cmdutil.RunSilently("svn", "info", copied.String()); err == nil { remote = &copied } } args = append(args, remote.String(), vg.dir) return run(vg.silent)("svn", args...) }, Update: func(vg *vcsGetOption) error { 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+)$`) // GitsvnBackend is the VCSBackend for git-svn var GitsvnBackend = &VCSBackend{ Clone: func(vg *vcsGetOption) error { orig := vg.dir vg.dir = svnBase(vg.dir) standard := orig == vg.dir dir, _ := filepath.Split(vg.dir) err := os.MkdirAll(dir, 0755) if err != nil { return err } var getSvnInfo = func(u string) (string, error) { buf := &bytes.Buffer{} cmd := exec.Command("svn", "info", u) cmd.Stdout = buf cmd.Stderr = io.Discard err := cmdutil.RunCommand(cmd, true) return buf.String(), err } var svnInfo string args := []string{"svn", "clone"} remote := vg.url if vg.branch != "" { copied := *remote remote = &copied remote.Path = svnBase(remote.Path) remote.Path += "/branches/" + url.PathEscape(vg.branch) standard = false } else if standard { copied := *remote copied.Path += trunk info, err := getSvnInfo(copied.String()) if err == nil { args = append(args, "-s") svnInfo = info } else { standard = false } } if vg.shallow { if svnInfo == "" { info, err := getSvnInfo(remote.String()) if err != nil { return err } svnInfo = info } m := svnLastRevReg.FindStringSubmatch(svnInfo) if len(m) < 2 { return fmt.Errorf("no revisions are taken from svn info output: %s", svnInfo) } args = append(args, fmt.Sprintf("-r%s:HEAD", m[1])) } args = append(args, remote.String(), vg.dir) return run(vg.silent)("git", args...) }, Update: func(vg *vcsGetOption) error { 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 var MercurialBackend = &VCSBackend{ // Mercurial seems not supporting shallow clone currently. Clone: func(vg *vcsGetOption) error { dir, _ := filepath.Split(vg.dir) err := os.MkdirAll(dir, 0755) if err != nil { return err } args := []string{"clone"} if vg.branch != "" { args = append(args, "--branch", vg.branch) } args = append(args, vg.url.String(), vg.dir) return run(vg.silent)("hg", args...) }, Update: func(vg *vcsGetOption) error { return runInDir(vg.silent)(vg.dir, "hg", "pull", "--update") }, Init: func(dir string) error { 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 var DarcsBackend = &VCSBackend{ Clone: func(vg *vcsGetOption) error { if vg.branch != "" { return errors.New("darcs does not support branch") } dir, _ := filepath.Split(vg.dir) err := os.MkdirAll(dir, 0755) if err != nil { return err } args := []string{"get"} if vg.shallow { args = append(args, "--lazy") } args = append(args, vg.url.String(), vg.dir) return run(vg.silent)("darcs", args...) }, Update: func(vg *vcsGetOption) error { return runInDir(vg.silent)(vg.dir, "darcs", "pull") }, Init: func(dir string) error { 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.SplitSeq(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 var PijulBackend = &VCSBackend{ Clone: func(vg *vcsGetOption) error { dir, _ := filepath.Split(vg.dir) err := os.MkdirAll(dir, 0755) if err != nil { return err } args := []string{"clone"} if vg.branch != "" { args = append(args, "--channel", vg.branch) } args = append(args, vg.url.String(), vg.dir) return run(vg.silent)("pijul", args...) }, Update: func(vg *vcsGetOption) error { return runInDir(vg.silent)(vg.dir, "pijul", "pull") }, Init: func(dir string) error { 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.SplitSeq(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{ Clone: func(vg *vcsGetOption) error { return errors.New("CVS clone is not supported") }, Update: func(vg *vcsGetOption) error { return errors.New("CVS update is not supported") }, Contents: []string{"CVS/Repository"}, } const fossilRepoName = ".fossil" // same as Go // FossilBackend is the VCSBackend for fossil var FossilBackend = &VCSBackend{ Clone: func(vg *vcsGetOption) error { if vg.branch != "" { return errors.New("fossil does not support cloning specific branch") } if err := os.MkdirAll(vg.dir, 0755); err != nil { return err } if err := run(vg.silent)("fossil", "clone", vg.url.String(), filepath.Join(vg.dir, fossilRepoName)); err != nil { return err } return runInDir(vg.silent)(vg.dir, "fossil", "open", fossilRepoName) }, Update: func(vg *vcsGetOption) error { return runInDir(vg.silent)(vg.dir, "fossil", "update") }, Init: func(dir string) error { if err := cmdutil.RunInDir(dir, "fossil", "init", fossilRepoName); err != nil { return err } 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 var BazaarBackend = &VCSBackend{ // bazaar seems not supporting shallow clone currently. Clone: func(vg *vcsGetOption) error { if vg.branch != "" { return errors.New("--branch option is unavailable for Bazaar since branch is included in remote URL") } dir, _ := filepath.Split(vg.dir) err := os.MkdirAll(dir, 0755) if err != nil { return err } return run(vg.silent)("bzr", "branch", vg.url.String(), vg.dir) }, Update: func(vg *vcsGetOption) error { // Without --overwrite bzr will not pull tags that changed. return runInDir(vg.silent)(vg.dir, "bzr", "pull", "--overwrite") }, Init: func(dir string) error { 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{ "git": GitBackend, "github": GitBackend, "codecommit": GitBackend, "svn": SubversionBackend, "subversion": SubversionBackend, "git-svn": GitsvnBackend, "hg": MercurialBackend, "mercurial": MercurialBackend, "darcs": DarcsBackend, "pijul": PijulBackend, "fossil": FossilBackend, "bzr": BazaarBackend, "bazaar": BazaarBackend, }