package main import ( "errors" "fmt" "net/url" "os" "path/filepath" "slices" "strings" "sync" "github.com/Songmu/gitconfig" "github.com/saracen/walker" "github.com/x-motemen/ghq/logger" ) const envGhqRoot = "GHQ_ROOT" // LocalRepository represents local repository type LocalRepository struct { FullPath string RelPath string RootPath string PathParts []string repoPath string vcsBackend *VCSBackend } // RepoPath returns local repository path func (repo *LocalRepository) RepoPath() string { if repo.repoPath != "" { return repo.repoPath } return repo.FullPath } // LocalRepositoryFromFullPath resolve LocalRepository from file path func LocalRepositoryFromFullPath(fullPath string, backend *VCSBackend) (*LocalRepository, error) { var relPath string roots, err := localRepositoryRoots(true) if err != nil { return nil, err } var root string for _, root = range roots { if !strings.HasPrefix(fullPath, root) { continue } var err error relPath, err = filepath.Rel(root, fullPath) if err == nil { break } } if relPath == "" { return nil, fmt.Errorf("no local repository found for: %s", fullPath) } pathParts := strings.Split(relPath, string(filepath.Separator)) return &LocalRepository{ FullPath: fullPath, RelPath: filepath.ToSlash(relPath), RootPath: root, PathParts: pathParts, vcsBackend: backend, }, nil } // LocalRepositoryFromURL resolve LocalRepository from URL func LocalRepositoryFromURL(remoteURL *url.URL, bare bool) (*LocalRepository, error) { pathParts := append( []string{remoteURL.Hostname()}, strings.Split(remoteURL.Path, "/")..., ) relPath := strings.TrimSuffix(filepath.Join(pathParts...), ".git") pathParts[len(pathParts)-1] = strings.TrimSuffix(pathParts[len(pathParts)-1], ".git") if bare { // Force to append ".git" even if remoteURL does not end with ".git". relPath = relPath + ".git" pathParts[len(pathParts)-1] = pathParts[len(pathParts)-1] + ".git" } var ( localRepository *LocalRepository mu sync.Mutex ) // Find existing local repository first if err := walkAllLocalRepositories(func(repo *LocalRepository) { if repo.RelPath == relPath { mu.Lock() localRepository = repo mu.Unlock() } }); err != nil { return nil, err } if localRepository != nil { return localRepository, nil } var remoteURLStr = remoteURL.String() if remoteURL.Scheme == "codecommit" { remoteURLStr = remoteURL.Opaque } prim, err := getRoot(remoteURLStr) if err != nil { return nil, err } // No local repository found, returning new one return &LocalRepository{ FullPath: filepath.Join(prim, relPath), RelPath: relPath, RootPath: prim, PathParts: pathParts, }, nil } func getRoot(u string) (string, error) { prim := os.Getenv(envGhqRoot) if prim != "" { return prim, nil } var err error if !codecommitLikeURLPattern.MatchString(u) { prim, err = gitconfig.Do("--path", "--get-urlmatch", "ghq.root", u) if err != nil && !gitconfig.IsNotFound(err) { return "", err } } if prim == "" { prim, err = primaryLocalRepositoryRoot() if err != nil { return "", err } } return prim, nil } // Subpaths returns lists of tail parts of relative path from the root directory (shortest first) // for example, {"ghq", "motemen/ghq", "github.com/motemen/ghq"} for $root/github.com/motemen/ghq. func (repo *LocalRepository) Subpaths() []string { tails := make([]string, len(repo.PathParts)) for i := range repo.PathParts { tails[i] = strings.Join(repo.PathParts[len(repo.PathParts)-(i+1):], "/") } return tails } // NonHostPath returns non host path func (repo *LocalRepository) NonHostPath() string { return strings.Join(repo.PathParts[1:], "/") } // list as bellow // - "$GHQ_ROOT/github.com/motemen/ghq/cmdutil" // repo.FullPath // - "$GHQ_ROOT/github.com/motemen/ghq" // - "$GHQ_ROOT/github.com/motemen func (repo *LocalRepository) repoRootCandidates() []string { hostRoot := filepath.Join(repo.RootPath, repo.PathParts[0]) nonHostParts := repo.PathParts[1:] candidates := make([]string, len(nonHostParts)) for i := range nonHostParts { candidates[i] = filepath.Join(append( []string{hostRoot}, nonHostParts[0:len(nonHostParts)-i]...)...) } return candidates } // IsUnderPrimaryRoot or not func (repo *LocalRepository) IsUnderPrimaryRoot() bool { prim, err := primaryLocalRepositoryRoot() if err != nil { return false } return strings.HasPrefix(repo.FullPath, prim) } // Matches checks if any subpath of the local repository equals the query. func (repo *LocalRepository) Matches(pathQuery string) bool { return slices.Contains(repo.Subpaths(), pathQuery) } // VCS returns VCSBackend of the repository func (repo *LocalRepository) VCS() (*VCSBackend, string) { if repo.vcsBackend == nil { for _, dir := range repo.repoRootCandidates() { backend := findVCSBackend(dir, "") if backend != nil { repo.vcsBackend = backend repo.repoPath = dir break } } } return repo.vcsBackend, repo.RepoPath() } var vcsContentsMap = map[string]*VCSBackend{ ".git": GitBackend, ".hg": MercurialBackend, ".svn": SubversionBackend, "_darcs": DarcsBackend, ".pijul": PijulBackend, ".bzr": BazaarBackend, ".fslckout": FossilBackend, // file "_FOSSIL_": FossilBackend, // file "CVS/Repository": cvsDummyBackend, } var vcsContents = [...]string{ ".git", ".hg", ".svn", "_darcs", ".pijul", ".bzr", ".fslckout", "._FOSSIL_", "CVS/Repository", } func findVCSBackend(fpath, vcs string) *VCSBackend { // When vcs is not empty, search only specified contents of vcs if vcs != "" { vcsBackend, ok := vcsRegistry[vcs] if !ok { return nil } if vcsBackend == GitBackend && strings.HasSuffix(fpath, ".git") { return vcsBackend } for _, d := range vcsBackend.Contents { if _, err := os.Stat(filepath.Join(fpath, d)); err == nil { return vcsBackend } } return nil } if strings.HasSuffix(fpath, ".git") { return GitBackend } for _, d := range vcsContents { if _, err := os.Stat(filepath.Join(fpath, d)); err == nil { return vcsContentsMap[d] } } return nil } func walkAllLocalRepositories(callback func(*LocalRepository)) error { return walkLocalRepositories("", callback) } func walkLocalRepositories(vcs string, callback func(*LocalRepository)) error { roots, err := localRepositoryRoots(true) if err != nil { return err } walkFn := func(fpath string, fi os.FileInfo) error { isSymlink := false if fi.Mode()&os.ModeSymlink == os.ModeSymlink { isSymlink = true realpath, err := filepath.EvalSymlinks(fpath) if err != nil { return nil } fi, err = os.Stat(realpath) if err != nil { return nil } } if !fi.IsDir() { return nil } vcsBackend := findVCSBackend(fpath, vcs) if vcsBackend == nil { return nil } repo, err := LocalRepositoryFromFullPath(fpath, vcsBackend) if err != nil || repo == nil { return nil } callback(repo) if isSymlink { return nil } return filepath.SkipDir } errCb := walker.WithErrorCallback(func(pathname string, err error) error { if os.IsPermission(errors.Unwrap(err)) { logger.Log("warning", fmt.Sprintf("%s: Permission denied", pathname)) return nil } return err }) for _, root := range roots { fi, err := os.Stat(root) if err != nil { if os.IsNotExist(err) { continue } } if fi.Mode()&0444 == 0 { logger.Log("warning", fmt.Sprintf("%s: Permission denied", root)) continue } if err := walker.Walk(root, walkFn, errCb); err != nil { return err } } return nil } var ( _home string _homeErr error homeOnce = &sync.Once{} ) func getHome() (string, error) { homeOnce.Do(func() { _home, _homeErr = os.UserHomeDir() }) return _home, _homeErr } var ( _localRepositoryRoots []string _localRepoErr error localRepoOnce = &sync.Once{} ) // localRepositoryRoots returns locally cloned repositories' root directories. // The root dirs are determined as following: // // - If GHQ_ROOT environment variable is nonempty, use it as the only root dir. // - Otherwise, use the result of `git config --get-all ghq.root` as the dirs. // - Otherwise, fallback to the default root, `~/.ghq`. // - When GHQ_ROOT is empty, specific root dirs are added from the result of // `git config --path --get-regexp '^ghq\..+\.root$` func localRepositoryRoots(all bool) ([]string, error) { localRepoOnce.Do(func() { var roots []string envRoot := os.Getenv(envGhqRoot) if envRoot != "" { roots = filepath.SplitList(envRoot) } else { var err error roots, err = gitconfig.PathAll("ghq.root") if err != nil && !gitconfig.IsNotFound(err) { _localRepoErr = err return } // reverse slice for i := len(roots)/2 - 1; i >= 0; i-- { opp := len(roots) - 1 - i roots[i], roots[opp] = roots[opp], roots[i] } } if len(roots) == 0 { homeDir, err := getHome() if err != nil { _localRepoErr = err return } roots = []string{filepath.Join(homeDir, "ghq")} } if all && envRoot == "" { localRoots, err := urlMatchLocalRepositoryRoots() if err != nil { _localRepoErr = err return } roots = append(roots, localRoots...) } seen := make(map[string]bool, len(roots)) for _, v := range roots { path := filepath.Clean(v) if _, err := os.Stat(path); err == nil { if path, err = evalSymlinks(path); err != nil { _localRepoErr = err return } } if !filepath.IsAbs(path) { var err error if path, err = filepath.Abs(path); err != nil { _localRepoErr = err return } } if seen[path] { continue } seen[path] = true _localRepositoryRoots = append(_localRepositoryRoots, path) } }) return _localRepositoryRoots, _localRepoErr } func urlMatchLocalRepositoryRoots() ([]string, error) { out, err := gitconfig.Do("--path", "--get-regexp", `^ghq\..+\.root$`) if err != nil { if gitconfig.IsNotFound(err) { return nil, nil } return nil, err } items := strings.Split(out, "\x00") ret := make([]string, len(items)) for i, kvStr := range items { kv := strings.SplitN(kvStr, "\n", 2) ret[i] = kv[1] } return ret, nil } func primaryLocalRepositoryRoot() (string, error) { roots, err := localRepositoryRoots(false) if err != nil { return "", err } return roots[0], nil }