package vcs
import (
"encoding/xml"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// NewSvnRepo creates a new instance of SvnRepo. The remote and local directories
// need to be passed in. The remote location should include the branch for SVN.
// For example, if the package is https://github.com/Masterminds/cookoo/ the remote
// should be https://github.com/Masterminds/cookoo/trunk for the trunk branch.
func NewSvnRepo(remote, local string) (*SvnRepo, error) {
ins := depInstalled("svn")
if !ins {
return nil, NewLocalError("svn is not installed", nil, "")
}
ltype, err := DetectVcsFromFS(local)
// Found a VCS other than Svn. Need to report an error.
if err == nil && ltype != Svn {
return nil, ErrWrongVCS
}
r := &SvnRepo{}
r.setRemote(remote)
r.setLocalPath(local)
r.Logger = Logger
// Make sure the local SVN repo is configured the same as the remote when
// A remote value was passed in.
if err == nil && r.CheckLocal() {
// An SVN repo was found so test that the URL there matches
// the repo passed in here.
out, err := exec.Command("svn", "info", local).CombinedOutput()
if err != nil {
return nil, NewLocalError("Unable to retrieve local repo information", err, string(out))
}
detectedRemote, err := detectRemoteFromInfoCommand(string(out))
if err != nil {
return nil, NewLocalError("Unable to retrieve local repo information", err, string(out))
}
if detectedRemote != "" && remote != "" && detectedRemote != remote {
return nil, ErrWrongRemote
}
// If no remote was passed in but one is configured for the locally
// checked out Svn repo use that one.
if remote == "" && detectedRemote != "" {
r.setRemote(detectedRemote)
}
}
return r, nil
}
// SvnRepo implements the Repo interface for the Svn source control.
type SvnRepo struct {
base
}
// Vcs retrieves the underlying VCS being implemented.
func (s SvnRepo) Vcs() Type {
return Svn
}
// Get is used to perform an initial checkout of a repository.
// Note, because SVN isn't distributed this is a checkout without
// a clone.
func (s *SvnRepo) Get() error {
remote := s.Remote()
if strings.HasPrefix(remote, "/") {
remote = "file://" + remote
} else if runtime.GOOS == "windows" && filepath.VolumeName(remote) != "" {
remote = "file:///" + remote
}
out, err := s.run("svn", "checkout", remote, s.LocalPath())
if err != nil {
return NewRemoteError("Unable to get repository", err, string(out))
}
return nil
}
// Init will create a svn repository at remote location.
func (s *SvnRepo) Init() error {
out, err := s.run("svnadmin", "create", s.Remote())
if err != nil && s.isUnableToCreateDir(err) {
basePath := filepath.Dir(filepath.FromSlash(s.Remote()))
if _, err := os.Stat(basePath); os.IsNotExist(err) {
err = os.MkdirAll(basePath, 0755)
if err != nil {
return NewLocalError("Unable to initialize repository", err, "")
}
out, err = s.run("svnadmin", "create", s.Remote())
if err != nil {
return NewLocalError("Unable to initialize repository", err, string(out))
}
return nil
}
} else if err != nil {
return NewLocalError("Unable to initialize repository", err, string(out))
}
return nil
}
// Update performs an SVN update to an existing checkout.
func (s *SvnRepo) Update() error {
out, err := s.RunFromDir("svn", "update")
if err != nil {
return NewRemoteError("Unable to update repository", err, string(out))
}
return err
}
// UpdateVersion sets the version of a package currently checked out via SVN.
func (s *SvnRepo) UpdateVersion(version string) error {
out, err := s.RunFromDir("svn", "update", "-r", version)
if err != nil {
return NewRemoteError("Unable to update checked out version", err, string(out))
}
return nil
}
// Version retrieves the current version.
func (s *SvnRepo) Version() (string, error) {
type Commit struct {
Revision string `xml:"revision,attr"`
}
type Info struct {
Commit Commit `xml:"entry>commit"`
}
out, err := s.RunFromDir("svn", "info", "--xml")
if err != nil {
return "", NewLocalError("Unable to retrieve checked out version", err, string(out))
}
s.log(out)
infos := &Info{}
err = xml.Unmarshal(out, &infos)
if err != nil {
return "", NewLocalError("Unable to retrieve checked out version", err, string(out))
}
return infos.Commit.Revision, nil
}
// Current returns the current version-ish. This means:
// * HEAD if on the tip.
// * Otherwise a revision id
func (s *SvnRepo) Current() (string, error) {
tip, err := s.CommitInfo("HEAD")
if err != nil {
return "", err
}
curr, err := s.Version()
if err != nil {
return "", err
}
if tip.Commit == curr {
return "HEAD", nil
}
return curr, nil
}
// Date retrieves the date on the latest commit.
func (s *SvnRepo) Date() (time.Time, error) {
version, err := s.Version()
if err != nil {
return time.Time{}, NewLocalError("Unable to retrieve revision date", err, "")
}
out, err := s.RunFromDir("svn", "pget", "svn:date", "--revprop", "-r", version)
if err != nil {
return time.Time{}, NewLocalError("Unable to retrieve revision date", err, string(out))
}
const longForm = "2006-01-02T15:04:05.000000Z"
t, err := time.Parse(longForm, strings.TrimSpace(string(out)))
if err != nil {
return time.Time{}, NewLocalError("Unable to retrieve revision date", err, string(out))
}
return t, nil
}
// CheckLocal verifies the local location is an SVN repo.
func (s *SvnRepo) CheckLocal() bool {
pth, err := filepath.Abs(s.LocalPath())
if err != nil {
s.log(err.Error())
return false
}
if _, err := os.Stat(filepath.Join(pth, ".svn")); err == nil {
return true
}
oldpth := pth
for oldpth != pth {
pth = filepath.Dir(pth)
if _, err := os.Stat(filepath.Join(pth, ".svn")); err == nil {
return true
}
}
return false
}
// Tags returns []string{} as there are no formal tags in SVN. Tags are a
// convention in SVN. They are typically implemented as a copy of the trunk and
// placed in the /tags/[tag name] directory. Since this is a convention the
// expectation is to checkout a tag the correct subdirectory will be used
// as the path. For more information see:
// http://svnbook.red-bean.com/en/1.7/svn.branchmerge.tags.html
func (s *SvnRepo) Tags() ([]string, error) {
return []string{}, nil
}
// Branches returns []string{} as there are no formal branches in SVN. Branches
// are a convention. They are typically implemented as a copy of the trunk and
// placed in the /branches/[tag name] directory. Since this is a convention the
// expectation is to checkout a branch the correct subdirectory will be used
// as the path. For more information see:
// http://svnbook.red-bean.com/en/1.7/svn.branchmerge.using.html
func (s *SvnRepo) Branches() ([]string, error) {
return []string{}, nil
}
// IsReference returns if a string is a reference. A reference is a commit id.
// Branches and tags are part of the path.
func (s *SvnRepo) IsReference(r string) bool {
out, err := s.RunFromDir("svn", "log", "-r", r)
// This is a complete hack. There must be a better way to do this. Pull
// requests welcome. When the reference isn't real you get a line of
// repeated - followed by an empty line. If the reference is real there
// is commit information in addition to those. So, we look for responses
// over 2 lines long.
lines := strings.Split(string(out), "\n")
if err == nil && len(lines) > 2 {
return true
}
return false
}
// IsDirty returns if the checkout has been modified from the checked
// out reference.
func (s *SvnRepo) IsDirty() bool {
out, err := s.RunFromDir("svn", "diff")
return err != nil || len(out) != 0
}
// CommitInfo retrieves metadata about a commit.
func (s *SvnRepo) CommitInfo(id string) (*CommitInfo, error) {
// There are cases where Svn log doesn't return anything for HEAD or BASE.
// svn info does provide details for these but does not have elements like
// the commit message.
if id == "HEAD" || id == "BASE" {
type Commit struct {
Revision string `xml:"revision,attr"`
}
type Info struct {
Commit Commit `xml:"entry>commit"`
}
out, err := s.RunFromDir("svn", "info", "-r", id, "--xml")
if err != nil {
return nil, NewLocalError("Unable to retrieve commit information", err, string(out))
}
infos := &Info{}
err = xml.Unmarshal(out, &infos)
if err != nil {
return nil, NewLocalError("Unable to retrieve commit information", err, string(out))
}
id = infos.Commit.Revision
if id == "" {
return nil, ErrRevisionUnavailable
}
}
out, err := s.RunFromDir("svn", "log", "-r", id, "--xml")
if err != nil {
return nil, NewRemoteError("Unable to retrieve commit information", err, string(out))
}
type Logentry struct {
Author string `xml:"author"`
Date string `xml:"date"`
Msg string `xml:"msg"`
}
type Log struct {
XMLName xml.Name `xml:"log"`
Logs []Logentry `xml:"logentry"`
}
logs := &Log{}
err = xml.Unmarshal(out, &logs)
if err != nil {
return nil, NewLocalError("Unable to retrieve commit information", err, string(out))
}
if len(logs.Logs) == 0 {
return nil, ErrRevisionUnavailable
}
ci := &CommitInfo{
Commit: id,
Author: logs.Logs[0].Author,
Message: logs.Logs[0].Msg,
}
if len(logs.Logs[0].Date) > 0 {
ci.Date, err = time.Parse(time.RFC3339Nano, logs.Logs[0].Date)
if err != nil {
return nil, NewLocalError("Unable to retrieve commit information", err, string(out))
}
}
return ci, nil
}
// TagsFromCommit retrieves tags from a commit id.
func (s *SvnRepo) TagsFromCommit(id string) ([]string, error) {
// Svn tags are a convention implemented as paths. See the details on the
// Tag() method for more information.
return []string{}, nil
}
// Ping returns if remote location is accessible.
func (s *SvnRepo) Ping() bool {
_, err := s.run("svn", "--non-interactive", "info", s.Remote())
return err == nil
}
// ExportDir exports the current revision to the passed in directory.
func (s *SvnRepo) ExportDir(dir string) error {
out, err := s.RunFromDir("svn", "export", ".", dir)
s.log(out)
if err != nil {
return NewLocalError("Unable to export source", err, string(out))
}
return nil
}
// isUnableToCreateDir checks for an error in Init() to see if an error
// where the parent directory of the VCS local path doesn't exist.
func (s *SvnRepo) isUnableToCreateDir(err error) bool {
msg := err.Error()
return strings.HasPrefix(msg, "E000002")
}
// detectRemoteFromInfoCommand finds the remote url from the `svn info`
// command's output without using a regex. We avoid regex because URLs
// are notoriously complex to accurately match with a regex and
// splitting strings is less complex and often faster
func detectRemoteFromInfoCommand(infoOut string) (string, error) {
sBytes := []byte(infoOut)
urlIndex := strings.Index(infoOut, "URL: ")
if urlIndex == -1 {
return "", fmt.Errorf("Remote not specified in svn info")
}
urlEndIndex := strings.Index(string(sBytes[urlIndex:]), "\n")
if urlEndIndex == -1 {
urlEndIndex = strings.Index(string(sBytes[urlIndex:]), "\r")
if urlEndIndex == -1 {
return "", fmt.Errorf("Unable to parse remote URL for svn info")
}
}
return string(sBytes[(urlIndex + 5):(urlIndex + urlEndIndex)]), nil
}