Skip to content

Commit 3d20c37

Browse files
fentasclaude
andauthored
feat: add git:// provider for sourcing binaries from git repos (#110)
Enables b.yaml to reference binaries from local or remote git repositories using the git://<repo>:<filepath> pattern, completing the provider system alongside GitHub, GitLab, Gitea, go://, and docker:// providers. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cefabda commit 3d20c37

File tree

5 files changed

+502
-2
lines changed

5 files changed

+502
-2
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
Features:
2323

2424
- **30+ pre-packaged binaries** (kubectl, k9s, jq, helm, etc.) with auto-detection
25-
- **Install from GitHub, GitLab, Gitea/Forgejo, Go modules, and Docker images** ([docs](https://binary.help/b/subcommands/install))
25+
- **Install from GitHub, GitLab, Gitea/Forgejo, Go modules, Docker images, and git repos** ([docs](https://binary.help/b/subcommands/install))
2626
- **Sync env files** from git repos with glob matching and three-way merge
2727
- **Lockfile** (`b.lock`) for reproducible installations with SHA256 verification
2828
- **direnv integration** for per-project binary management
@@ -49,6 +49,8 @@ b install gitlab.com/org/tool
4949
b install codeberg.org/user/app@v1.0
5050
b install go://golang.org/x/tools/cmd/goimports
5151
b install docker://alpine/helm
52+
b install "git:///home/user/myrepo:.scripts/tool"
53+
b install "git://github.com/org/repo:bin/app@v1.0"
5254

5355
# Install and add to b.yaml
5456
b install --add jq@1.7
@@ -114,9 +116,13 @@ binaries:
114116
alias: renvsubst # alias to renvsubst
115117
kubectl:
116118
file: ../kc # custom path (relative to config)
117-
# Install from any provider by ref (GitHub, GitLab, Gitea, go://, docker://)
119+
# Install from any provider by ref (GitHub, GitLab, Gitea, go://, docker://, git://)
118120
github.com/sharkdp/bat:
119121
version: v0.24.0
122+
# Install from a git repo (local or remote)
123+
git:///home/user/myproject:.scripts/tool:
124+
git://github.com/org/repo:bin/app:
125+
version: v1.0
120126

121127
envs:
122128
# Sync files from upstream git repos

pkg/binary/download.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,13 @@ func (b *Binary) downloadViaProvider() error {
172172
}
173173
b.File = path
174174
return nil
175+
case *provider.Git:
176+
path, err := pt.Install(b.ProviderRef, b.Version, destDir)
177+
if err != nil {
178+
return err
179+
}
180+
b.File = path
181+
return nil
175182
}
176183

177184
// Release-based providers (GitHub, GitLab, Gitea)

pkg/provider/git.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package provider
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/fentas/b/pkg/gitcache"
11+
)
12+
13+
func init() {
14+
Register(&Git{})
15+
}
16+
17+
// Git sources binaries from git repositories (local or remote).
18+
//
19+
// Ref format: git://<repo>:<filepath>
20+
// - Local: git:///absolute/path/to/repo:.scripts/lo
21+
// - Remote: git://github.com/org/repo:scripts/tool.sh
22+
type Git struct{}
23+
24+
func (g *Git) Name() string { return "git" }
25+
26+
func (g *Git) Match(ref string) bool {
27+
return strings.HasPrefix(ref, "git://")
28+
}
29+
30+
// LatestVersion returns the HEAD commit SHA for the repo.
31+
func (g *Git) LatestVersion(ref string) (string, error) {
32+
repo, _, err := parseGitRef(ref)
33+
if err != nil {
34+
return "", err
35+
}
36+
37+
if isLocalRepo(repo) {
38+
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
39+
if err != nil {
40+
return "", fmt.Errorf("git rev-parse HEAD in %s: %w", repo, err)
41+
}
42+
return strings.TrimSpace(string(out)), nil
43+
}
44+
45+
url := "https://" + repo + ".git"
46+
return gitcache.ResolveRef(url, "HEAD")
47+
}
48+
49+
// FetchRelease is not used for git — use Install instead.
50+
func (g *Git) FetchRelease(ref, version string) (*Release, error) {
51+
return nil, fmt.Errorf("git provider does not use FetchRelease; use Install()")
52+
}
53+
54+
// Install extracts a single file from a git repo and copies it to destDir.
55+
func (g *Git) Install(ref, version, destDir string) (string, error) {
56+
repo, filePath, err := parseGitRef(ref)
57+
if err != nil {
58+
return "", err
59+
}
60+
61+
name := filepath.Base(filePath)
62+
dest := filepath.Join(destDir, name)
63+
if err := os.MkdirAll(destDir, 0755); err != nil {
64+
return "", err
65+
}
66+
67+
if isLocalRepo(repo) {
68+
return g.installFromLocal(repo, filePath, version, dest)
69+
}
70+
return g.installFromRemote(repo, filePath, version, dest)
71+
}
72+
73+
// installFromLocal extracts a file from a local git repo.
74+
func (g *Git) installFromLocal(repo, filePath, version, dest string) (string, error) {
75+
treeish := version
76+
if treeish == "" {
77+
treeish = "HEAD"
78+
}
79+
80+
// Use git show to read the file at the given revision
81+
obj := treeish + ":" + filePath
82+
data, err := exec.Command("git", "-C", repo, "show", obj).Output()
83+
if err != nil {
84+
return "", fmt.Errorf("git show %s in %s: %w", obj, repo, err)
85+
}
86+
87+
if err := os.WriteFile(dest, data, 0755); err != nil {
88+
return "", err
89+
}
90+
return dest, nil
91+
}
92+
93+
// installFromRemote clones/caches a remote repo and extracts the file.
94+
func (g *Git) installFromRemote(repo, filePath, version, dest string) (string, error) {
95+
cacheRoot := gitcache.DefaultCacheRoot()
96+
url := "https://" + repo + ".git"
97+
98+
if err := gitcache.EnsureClone(cacheRoot, repo, url); err != nil {
99+
return "", fmt.Errorf("cloning %s: %w", url, err)
100+
}
101+
102+
commit := version
103+
if commit == "" {
104+
var err error
105+
commit, err = gitcache.ResolveRef(url, "HEAD")
106+
if err != nil {
107+
return "", err
108+
}
109+
}
110+
111+
// Fetch the specific ref if not already present
112+
if err := gitcache.Fetch(cacheRoot, repo, commit); err != nil {
113+
// Ignore fetch errors if the commit is already cached
114+
_ = err
115+
}
116+
117+
data, err := gitcache.ShowFile(cacheRoot, repo, commit, filePath)
118+
if err != nil {
119+
return "", fmt.Errorf("reading %s at %s from %s: %w", filePath, commit, repo, err)
120+
}
121+
122+
if err := os.WriteFile(dest, data, 0755); err != nil {
123+
return "", err
124+
}
125+
return dest, nil
126+
}
127+
128+
// parseGitRef splits "git://<repo>:<filepath>" into repo and file path.
129+
// For local repos: git:///absolute/path:.scripts/lo -> ("/absolute/path", ".scripts/lo")
130+
// For remote repos: git://github.com/org/repo:path/file -> ("github.com/org/repo", "path/file")
131+
func parseGitRef(ref string) (repo, filePath string, err error) {
132+
raw := strings.TrimPrefix(ref, "git://")
133+
if raw == "" {
134+
return "", "", fmt.Errorf("empty git ref: %s", ref)
135+
}
136+
137+
// Strip version suffix (@v1.0.0) before parsing
138+
raw, _ = ParseRef("git://" + raw)
139+
raw = strings.TrimPrefix(raw, "git://")
140+
141+
// Local absolute path: starts with /
142+
// The colon separator between repo and filepath must be found carefully.
143+
// For local: /home/user/repo:.scripts/lo — first colon after the path
144+
// For remote: github.com/org/repo:scripts/tool.sh — first colon
145+
if strings.HasPrefix(raw, "/") {
146+
// Local path: find colon that separates repo path from file path
147+
idx := strings.Index(raw, ":")
148+
if idx < 0 {
149+
return "", "", fmt.Errorf("git ref missing filepath separator ':' — expected git://<repo>:<filepath>, got %s", ref)
150+
}
151+
return raw[:idx], raw[idx+1:], nil
152+
}
153+
154+
// Remote: find the colon separator
155+
idx := strings.Index(raw, ":")
156+
if idx < 0 {
157+
return "", "", fmt.Errorf("git ref missing filepath separator ':' — expected git://<repo>:<filepath>, got %s", ref)
158+
}
159+
return raw[:idx], raw[idx+1:], nil
160+
}
161+
162+
// isLocalRepo returns true if the repo path is an absolute local path.
163+
func isLocalRepo(repo string) bool {
164+
return strings.HasPrefix(repo, "/")
165+
}

0 commit comments

Comments
 (0)