diff --git a/remote_repository.go b/remote_repository.go index 3eb4241c..93dadacf 100644 --- a/remote_repository.go +++ b/remote_repository.go @@ -127,6 +127,11 @@ func (repo *OtherRepository) VCS() (*VCSBackend, *url.URL) { return backend, repo.URL() } + mayBeSvn := strings.HasPrefix(repo.url.Host, "svn.") + if mayBeSvn && cmdutil.RunSilently("svn", "info", repo.url.String()) == nil { + return SubversionBackend, repo.URL() + } + // Detect VCS backend automatically if cmdutil.RunSilently("git", "ls-remote", repo.url.String()) == nil { return GitBackend, repo.URL() @@ -142,7 +147,7 @@ func (repo *OtherRepository) VCS() (*VCSBackend, *url.URL) { return MercurialBackend, repo.URL() } - if cmdutil.RunSilently("svn", "info", repo.url.String()) == nil { + if !mayBeSvn && cmdutil.RunSilently("svn", "info", repo.url.String()) == nil { return SubversionBackend, repo.URL() } diff --git a/vcs.go b/vcs.go index 45df9369..ec4abdc6 100644 --- a/vcs.go +++ b/vcs.go @@ -1,10 +1,16 @@ package main import ( + "bytes" "errors" + "fmt" + "io/ioutil" "net/url" "os" + "os/exec" "path/filepath" + "regexp" + "strings" "github.com/motemen/ghq/cmdutil" ) @@ -84,9 +90,46 @@ var GitBackend = &VCSBackend{ Contents: []string{".git"}, } +/* +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". + +The checkout rule using "git-svn" also has the same behavior. +*/ + +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 strings.HasSuffix(p, trunk) { + return strings.TrimSuffix(p, trunk) + } + 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 { @@ -95,13 +138,20 @@ var SubversionBackend = &VCSBackend{ args := []string{"checkout"} if vg.shallow { - args = append(args, "--depth", "1") + 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) @@ -113,23 +163,66 @@ var SubversionBackend = &VCSBackend{ Contents: []string{".svn"}, } +var svnLastRevReg = regexp.MustCompile(`(?m)^Last Changed Rev: (\d+)$`) + // GitsvnBackend is the VCSBackend for git-svn var GitsvnBackend = &VCSBackend{ - // git-svn seems not supporting shallow clone currently. 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 = ioutil.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 + } } - return run(vg.silent)("git", "svn", "clone", remote.String(), vg.dir) + 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") diff --git a/vcs_test.go b/vcs_test.go index a5af9fa4..a247587a 100644 --- a/vcs_test.go +++ b/vcs_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "os/exec" "path/filepath" @@ -23,6 +24,9 @@ func TestVCSBackend(t *testing.T) { }(cmdutil.CommandRunner) cmdutil.CommandRunner = func(cmd *exec.Cmd) error { _commands = append(_commands, cmd) + if reflect.DeepEqual(cmd.Args, []string{"svn", "info", "https://example.com/git/repo/trunk"}) { + return fmt.Errorf("[test] failed to svn info") + } return nil } @@ -74,13 +78,13 @@ func TestVCSBackend(t *testing.T) { name: "[git] recursive", f: func() error { return GitBackend.Clone(&vcsGetOption{ - url: remoteDummyURL, - dir: localDir, + url: remoteDummyURL, + dir: localDir, recursive: true, }) }, expect: []string{"git", "clone", "--recursive", remoteDummyURL.String(), localDir}, - },{ + }, { name: "[svn] checkout", f: func() error { return SubversionBackend.Clone(&vcsGetOption{ @@ -98,7 +102,7 @@ func TestVCSBackend(t *testing.T) { shallow: true, }) }, - expect: []string{"svn", "checkout", "--depth", "1", remoteDummyURL.String(), localDir}, + expect: []string{"svn", "checkout", "--depth", "immediates", remoteDummyURL.String(), localDir}, }, { name: "[svn] checkout specific branch", f: func() error { @@ -140,13 +144,33 @@ func TestVCSBackend(t *testing.T) { }, { name: "[git-svn] clone shallow", f: func() error { + defer func(orig func(cmd *exec.Cmd) error) { + cmdutil.CommandRunner = orig + }(cmdutil.CommandRunner) + cmdutil.CommandRunner = func(cmd *exec.Cmd) error { + _commands = append(_commands, cmd) + if reflect.DeepEqual(cmd.Args, []string{"svn", "info", "https://example.com/git/repo/trunk"}) { + cmd.Stdout.Write([]byte(`Path: trunk +URL: https://svn.apache.org/repos/asf/subversion/trunk +Relative URL: ^/subversion/trunk +Repository Root: https://svn.apache.org/repos/asf +Repository UUID: 13f79535-47bb-0310-9956-ffa450edef68 +Revision: 1872085 +Node Kind: directory +Last Changed Author: julianfoad +Last Changed Rev: 1872031 +Last Changed Date: 2019-08-16 15:16:45 +0900 (Fri, 16 Aug 2019) +`)) + } + return nil + } return GitsvnBackend.Clone(&vcsGetOption{ url: remoteDummyURL, dir: localDir, shallow: true, }) }, - expect: []string{"git", "svn", "clone", remoteDummyURL.String(), localDir}, + expect: []string{"git", "svn", "clone", "-s", "-r1872031:HEAD", remoteDummyURL.String(), localDir}, }, { name: "[git-svn] clone specific branch", f: func() error {