Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
some data
11 changes: 11 additions & 0 deletions examples/plugins/git-with-revision/devbox.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"packages": [],
"shell": {
"init_hook": [
"echo 'Welcome to devbox!' > /dev/null"
]
},
"include": [
"git+https://github.com/jetify-com/devbox-plugin-example.git?rev=d9c00334353c9b1294c7bd5dbea128c149b2eb3a"
]
}
4 changes: 4 additions & 0 deletions examples/plugins/git-with-revision/devbox.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"lockfile_version": "1",
"packages": {}
}
9 changes: 9 additions & 0 deletions examples/plugins/git-with-revision/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/bash

expected="I AM SET"
if [ "$MY_ENV_VAR" == "$expected" ]; then
echo "Success! MY_ENV_VAR is set to '$MY_ENV_VAR'"
else
echo "MY_ENV_VAR environment variable is not set to '$expected'"
exit 1
fi
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
some data
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
some data
13 changes: 13 additions & 0 deletions examples/plugins/git/devbox.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"packages": [],
"shell": {
"init_hook": [
"echo 'Welcome to devbox!' > /dev/null"
]
},
"include": [
"git+https://github.com/jetify-com/devbox-plugin-example.git",
"git+https://github.com/jetify-com/devbox-plugin-example.git?dir=custom-dir",
"git+https://github.com/jetify-com/devbox-plugin-example.git?ref=test/branch"
]
}
4 changes: 4 additions & 0 deletions examples/plugins/git/devbox.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"lockfile_version": "1",
"packages": {}
}
14 changes: 14 additions & 0 deletions examples/plugins/git/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/bash

expected="I AM SET (new value)"
custom_expected="I AM SET TO CUSTOM (new value)"
if [ "$MY_ENV_VAR" == "$expected" ] && [ "$MY_ENV_VAR_CUSTOM" == "$custom_expected" ]; then
echo "Success! MY_ENV_VAR is set to '$MY_ENV_VAR'"
echo "Success! MY_ENV_VAR_CUSTOM is set to '$MY_ENV_VAR_CUSTOM'"
else
echo "ERROR: MY_ENV_VAR environment variable is not set to '$expected' OR MY_ENV_VAR_CUSTOM variable is not set to '$custom_expected'"
exit 1
fi

echo BRANCH_ENV_VAR=$BRANCH_ENV_VAR
if [ "$BRANCH_ENV_VAR" != "I AM A BRANCH VAR" ]; then exit 1; fi;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
some data
12 changes: 12 additions & 0 deletions examples/plugins/v2-git/devbox.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"packages": [],
"shell": {
"init_hook": [
"echo 'Welcome to devbox!' > /dev/null"
]
},
"include": [
"git+https://github.com/jetify-com/devbox-plugin-example.git",
"git+https://github.com/jetify-com/devbox-plugin-example?dir=custom-dir"
]
}
4 changes: 4 additions & 0 deletions examples/plugins/v2-git/devbox.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"lockfile_version": "1",
"packages": {}
}
11 changes: 11 additions & 0 deletions examples/plugins/v2-git/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/bash

expected="I AM SET (new value)"
custom_expected="I AM SET TO CUSTOM (new value)"
if [ "$MY_ENV_VAR" == "$expected" ] && [ "$MY_ENV_VAR_CUSTOM" == "$custom_expected" ]; then
echo "Success! MY_ENV_VAR is set to '$MY_ENV_VAR'"
echo "Success! MY_ENV_VAR_CUSTOM is set to '$MY_ENV_VAR_CUSTOM'"
else
echo "ERROR: MY_ENV_VAR environment variable is not set to '$expected' OR MY_ENV_VAR_CUSTOM variable is not set to '$custom_expected'"
exit 1
fi
6 changes: 6 additions & 0 deletions internal/plugin/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ func getConfigIfAny(inc Includable, projectDir string) (*Config, error) {
return nil, errors.WithStack(err)
}
return buildConfig(includable, projectDir, string(content))
case *gitPlugin:
content, err := includable.Fetch()
if err != nil {
return nil, errors.WithStack(err)
}
return buildConfig(includable, projectDir, string(content))
case *LocalPlugin:
content, err := os.ReadFile(includable.Path())
if err != nil && !os.IsNotExist(err) {
Expand Down
194 changes: 194 additions & 0 deletions internal/plugin/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright 2024 Jetify Inc. and contributors. All rights reserved.
// Use of this source code is governed by the license in the LICENSE file.

package plugin

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"go.jetify.com/devbox/nix/flake"
)

type gitPlugin struct {
ref *flake.Ref
name string
}

// newGitPlugin creates a Git plugin from a flake reference.
// It uses git clone to fetch the repository.
func newGitPlugin(ref flake.Ref) (*gitPlugin, error) {
if ref.Type != flake.TypeGit {
return nil, fmt.Errorf("expected git flake reference, got %s", ref.Type)
}

name := generateGitPluginName(ref)

return &gitPlugin{
ref: &ref,
name: name,
}, nil
}

func generateGitPluginName(ref flake.Ref) string {
// Extract repository name from URL and append directory if specified
url := ref.URL
if url == "" {
return "unknown.git"
}

// Remove query parameters to get clean URL
if strings.Contains(url, "?") {
url = strings.Split(url, "?")[0]
}

url = strings.TrimSuffix(url, ".git")

parts := strings.Split(url, "/")
if len(parts) < 2 {
return "unknown.git"
}

// Use last two path components (e.g., "owner/repo")
repoParts := parts[len(parts)-2:]

name := strings.Join(repoParts, ".")
name = strings.ReplaceAll(name, "/", ".")

// Append directory to make name unique when multiple plugins
// from same repo are used
if ref.Dir != "" {
dirName := strings.ReplaceAll(ref.Dir, "/", ".")
name = name + "." + dirName
}

return name
}

// getBaseURL extracts the base Git URL without query parameters.
// Query parameters like ?dir=path are used by Nix flakes but not by git clone.
func (p *gitPlugin) getBaseURL() string {
baseURL := p.ref.URL
if strings.Contains(baseURL, "?") {
baseURL = strings.Split(baseURL, "?")[0]
}
return baseURL
}

func (p *gitPlugin) Fetch() ([]byte, error) {
content, err := p.FileContent("plugin.json")
if err != nil {
return nil, err
}
return content, nil
}

func (p *gitPlugin) cloneAndRead(subpath string) ([]byte, error) {
tempDir, err := os.MkdirTemp("", "devbox-git-plugin-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)

baseURL := p.getBaseURL()

cloneArgs := []string{"clone"}
if p.ref.Ref != "" {
cloneArgs = append(cloneArgs, "--depth", "1", "--branch", p.ref.Ref)
} else if p.ref.Rev == "" {
cloneArgs = append(cloneArgs, "--depth", "1")
}
cloneArgs = append(cloneArgs, baseURL, tempDir)
cloneCmd := exec.Command("git", cloneArgs...)

if isSSHURL(baseURL) {
gitSSHCommand := os.Getenv("GIT_SSH_COMMAND")
if gitSSHCommand == "" {
gitSSHCommand = "ssh -o StrictHostKeyChecking=accept-new"
}
cloneCmd.Env = append(os.Environ(), "GIT_SSH_COMMAND="+gitSSHCommand)
}

output, err := cloneCmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to clone repository %s: %w\nOutput: %s", p.ref.URL, err, string(output))
}

if p.ref.Rev != "" {
checkoutCmd := exec.Command("git", "checkout", p.ref.Rev)
checkoutCmd.Dir = tempDir
output, err := checkoutCmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to checkout revision %s: %w\nOutput: %s", p.ref.Rev, err, string(output))
}
}

// Read file from repository root or specified directory
filePath := filepath.Join(tempDir, subpath)
if p.ref.Dir != "" {
filePath = filepath.Join(tempDir, p.ref.Dir, subpath)
}

content, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
}

return content, nil
}

// isSSHURL checks if the given URL is an SSH URL.
// SSH URLs can be in formats:
// - ssh://user@host/path
// - git@host:path
// - ssh://git@host/path
func isSSHURL(url string) bool {
url = strings.TrimSpace(url)
// Check for explicit ssh:// protocol
if strings.HasPrefix(url, "ssh://") {
return true
}
// Check for git@host:path format (SCP-like syntax)
// This format uses colon after host, not port number
if strings.HasPrefix(url, "git@") && strings.Contains(url, ":") {
// Make sure it's not an HTTPS URL with port (e.g., https://git@host:443/path)
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return true
}
}
return false
}

func isBranchName(ref string) bool {
// Full commit hashes are 40 hex characters
if len(ref) == 40 {
for _, c := range ref {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return true
}
}
return false
}
return true
}

func (p *gitPlugin) CanonicalName() string {
return p.name
}

// Hash returns a unique hash for this plugin including directory.
// This ensures plugins from the same repo with different dirs are unique.
func (p *gitPlugin) Hash() string {
return fmt.Sprintf("%s-%s-%s-%s", p.ref.URL, p.ref.Rev, p.ref.Ref, p.ref.Dir)
}

func (p *gitPlugin) FileContent(subpath string) ([]byte, error) {
return p.cloneAndRead(subpath)
}

func (p *gitPlugin) LockfileKey() string {
return p.ref.String()
}
Loading