vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
| 1 | // Package client implements the client side of talking to a Vole instance. |
| 2 | // |
| 3 | // It owns the parts of the CLI that are not about parsing arguments or printing: |
| 4 | // the clone's metadata branch, the SSH verbs that read the roster and ask who a |
| 5 | // key belongs to, the object-transfer protocol, and publishing a site snapshot. |
| 6 | // Tools that extend Vole can use this package instead of shelling out to the |
| 7 | // vole binary. |
| 8 | package client |
| 9 | |
| 10 | import ( |
| 11 | "fmt" |
| 12 | "net/url" |
| 13 | "os" |
| 14 | "os/exec" |
| 15 | "strings" |
| 16 | ) |
| 17 | |
| 18 | // CurrentGitDir is the clone the command is working in. |
| 19 | func CurrentGitDir() (string, error) { |
| 20 | out, err := exec.Command("git", "rev-parse", "--absolute-git-dir").Output() |
| 21 | if err != nil { |
| 22 | return "", fmt.Errorf("not inside a git repository") |
| 23 | } |
| 24 | return strings.TrimSpace(string(out)), nil |
| 25 | } |
| 26 | |
| 27 | // GitDirOf asks a checkout where its git directory is, so a caller can commit |
| 28 | // into it without changing directory. |
| 29 | func GitDirOf(dir string) (string, error) { |
| 30 | out, err := exec.Command("git", "-C", dir, "rev-parse", "--absolute-git-dir").Output() |
| 31 | if err != nil { |
| 32 | return "", fmt.Errorf("%s is not a git checkout", dir) |
| 33 | } |
| 34 | return strings.TrimSpace(string(out)), nil |
| 35 | } |
| 36 | |
| 37 | // GitOut runs one git command in dir and returns its stdout. |
| 38 | func GitOut(dir string, args ...string) (string, error) { |
| 39 | cmd := exec.Command("git", args...) |
| 40 | cmd.Dir = dir |
| 41 | cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") |
| 42 | var out, stderr strings.Builder |
| 43 | cmd.Stdout, cmd.Stderr = &out, &stderr |
| 44 | if err := cmd.Run(); err != nil { |
| 45 | return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) |
| 46 | } |
| 47 | return out.String(), nil |
| 48 | } |
| 49 | |
| 50 | // IsRemoteURL reports whether a remote argument is a location rather than a name. |
| 51 | func IsRemoteURL(s string) bool { |
| 52 | return strings.Contains(s, "://") || strings.HasPrefix(s, "git@") |
| 53 | } |
| 54 | |
| 55 | // HasRemote reports whether the clone has a remote with this name. |
| 56 | func HasRemote(gitDir, name string) bool { |
| 57 | cmd := exec.Command("git", "config", "--get", "remote."+name+".url") |
| 58 | cmd.Dir = gitDir |
| 59 | return cmd.Run() == nil |
| 60 | } |
| 61 | |
| 62 | // RemoteURL reads the URL a metadata push would go to. A URL is a remote in its |
| 63 | // own right, which is what lets a CI runner or a fresh repository push without |
| 64 | // configuring a named remote. |
| 65 | func RemoteURL(gitDir, remote string) (string, error) { |
| 66 | if IsRemoteURL(remote) { |
| 67 | return remote, nil |
| 68 | } |
| 69 | out, err := GitOut(gitDir, "remote", "get-url", remote) |
| 70 | if err != nil { |
| 71 | return "", fmt.Errorf("no remote named %q; pass --remote or add one", remote) |
| 72 | } |
| 73 | u := strings.TrimSpace(out) |
| 74 | if u == "" { |
| 75 | return "", fmt.Errorf("remote %q has no URL", remote) |
| 76 | } |
| 77 | return u, nil |
| 78 | } |
| 79 | |
| 80 | // LSRemote asks a URL or a remote name where a ref points, or "" when it is gone. |
| 81 | // A clone is a cache — after an unpublish the local branch survives — so callers |
| 82 | // that need the live state ask the server rather than the checkout. |
| 83 | func LSRemote(gitDir, remote, ref string) (string, error) { |
| 84 | cmd := exec.Command("git", "ls-remote", "--quiet", remote, ref) |
| 85 | cmd.Dir = gitDir |
| 86 | var stderr strings.Builder |
| 87 | cmd.Stderr = &stderr |
| 88 | out, err := cmd.Output() |
| 89 | if err != nil { |
| 90 | return "", fmt.Errorf("ask %s about %s: %w: %s", remote, ref, err, strings.TrimSpace(stderr.String())) |
| 91 | } |
| 92 | for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { |
| 93 | f := strings.Fields(line) |
| 94 | if len(f) == 2 && f[1] == ref { |
| 95 | return f[0], nil |
| 96 | } |
| 97 | } |
| 98 | return "", nil |
| 99 | } |
| 100 | |
| 101 | // DefaultAuthor prefers an explicit vole.author, then git's user.name, then |
| 102 | // $USER. Vole usernames are lowercase ASCII, so the result is normalised to that. |
| 103 | func DefaultAuthor(gitDir string) string { |
| 104 | for _, key := range []string{"vole.author", "user.name"} { |
| 105 | out, err := GitOut(gitDir, "config", "--get", key) |
| 106 | if err == nil { |
| 107 | if s := SanitizeAuthor(out); s != "" { |
| 108 | return s |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | return SanitizeAuthor(os.Getenv("USER")) |
| 113 | } |
| 114 | |
| 115 | // SanitizeAuthor turns a person's name from git config into the shape of a Vole |
| 116 | // account name. |
| 117 | func SanitizeAuthor(s string) string { |
| 118 | s = strings.ToLower(strings.TrimSpace(s)) |
| 119 | var b strings.Builder |
| 120 | for _, r := range s { |
| 121 | switch { |
| 122 | case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_': |
| 123 | b.WriteRune(r) |
| 124 | case r == ' ' || r == '.': |
| 125 | b.WriteRune('-') |
| 126 | } |
| 127 | } |
| 128 | return strings.Trim(b.String(), "-") |
| 129 | } |
| 130 | |
| 131 | // RepoURL builds the SSH URL for one repository from an instance base URL, so an |
| 132 | // upload lands where the job's push would. The base is the instance's address, |
| 133 | // which need not name a repository itself. |
| 134 | func RepoURL(remote, repo string) (string, error) { |
| 135 | u, err := url.Parse(remote) |
| 136 | if err != nil { |
| 137 | return "", err |
| 138 | } |
| 139 | if u.Scheme != "ssh" || u.Host == "" { |
| 140 | return "", fmt.Errorf("%q is not an ssh URL (expected ssh://user@host:port)", remote) |
| 141 | } |
| 142 | return fmt.Sprintf("%s://%s/%s.git", u.Scheme, u.Host, repo), nil |
| 143 | } |
| 144 | |
| 145 | // RepoNameFromPath is the repository a remote path names: /demo.git -> demo. |
| 146 | func RepoNameFromPath(path string) string { |
| 147 | name := strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git") |
| 148 | if i := strings.IndexByte(name, '/'); i >= 0 { |
| 149 | name = name[i+1:] |
| 150 | } |
| 151 | return name |
| 152 | } |
| 153 |