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
82 changes: 64 additions & 18 deletions go/core/internal/skillsinit/git.go
Original file line number Diff line number Diff line change
@@ -1,42 +1,88 @@
package skillsinit

import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"

"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport/client"
"github.com/go-git/go-git/v5/plumbing/transport/server"
)

func init() {
// Replace go-git's default file:// transport (which shells out to git-upload-pack)
// with the pure-Go in-process filesystem server.
client.InstallProtocol("file", server.NewClient(server.NewFilesystemLoader(osfs.New("/"))))
}

var immutableGitCommit = regexp.MustCompile(`^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$`)

// CloneGitCommit fetches only one immutable commit instead of cloning the
// repository's complete history.
func CloneGitCommit(url, commit, destination string) error {
func CloneGitCommit(ctx context.Context, url, commit, destination string) error {
if !immutableGitCommit.MatchString(commit) {
return fmt.Errorf("git commit must be a full SHA")
}
if err := os.MkdirAll(destination, 0o755); err != nil {
return err
}
if err := runGitIn(destination, "init"); err != nil {
return err

// If url points to a local non-bare repository directory, point go-git's
// in-process file loader at its .git directory.
if info, err := os.Stat(filepath.Join(url, ".git")); err == nil && info.IsDir() {
url = filepath.Join(url, ".git")
}
if err := runGitIn(destination, "remote", "add", "origin", url); err != nil {
return err

repo, err := git.PlainInit(destination, false)
if err != nil {
return fmt.Errorf("git init: %w", err)
}
if err := runGitIn(destination, "fetch", "--depth", "1", "origin", commit); err != nil {
return err

if _, err := repo.CreateRemote(&config.RemoteConfig{
Name: "origin",
URLs: []string{url},
}); err != nil {
return fmt.Errorf("git remote add: %w", err)
}
return runGitIn(destination, "checkout", "--detach", "FETCH_HEAD")
}

func runGitIn(dir string, args ...string) error {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
refSpec := config.RefSpec(fmt.Sprintf("%s:refs/remotes/origin/%s", commit, commit))

// Try a shallow fetch of the specific commit first.
err = repo.FetchContext(ctx, &git.FetchOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{refSpec},
Depth: 1,
Tags: git.NoTags,
})
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
if ctx.Err() != nil {
return ctx.Err()
}
// Fallback for transports (like the in-process local filesystem loader)
// or servers that do not support shallow fetches by arbitrary SHA.
err = repo.FetchContext(ctx, &git.FetchOptions{
RemoteName: "origin",
Tags: git.AllTags,
})
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return fmt.Errorf("git fetch: %w", err)
}
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
return cmd.Run()

wt, err := repo.Worktree()
if err != nil {
return fmt.Errorf("git worktree: %w", err)
}

return wt.Checkout(&git.CheckoutOptions{
Hash: plumbing.NewHash(commit),
Force: true,
})
}
68 changes: 67 additions & 1 deletion go/core/internal/skillsinit/git_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,78 @@
package skillsinit

import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/require"
)

func TestCloneGitCommitRejectsMutableRef(t *testing.T) {
err := CloneGitCommit("https://example.com/repository.git", "main", t.TempDir())
err := CloneGitCommit(context.Background(), "https://example.com/repository.git", "main", t.TempDir())
require.ErrorContains(t, err, "full SHA")
}

func TestCloneGitCommitFetchesSpecificCommit(t *testing.T) {
src := t.TempDir()
repo, err := git.PlainInit(src, false)
require.NoError(t, err)

wt, err := repo.Worktree()
require.NoError(t, err)

sig := &object.Signature{
Name: "Test",
Email: "test@example.com",
When: time.Unix(1700000000, 0),
}

// First commit (the one we want to fetch)
require.NoError(t, os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# v1"), 0o644))
_, err = wt.Add("SKILL.md")
require.NoError(t, err)
firstCommit, err := wt.Commit("first", &git.CommitOptions{Author: sig, Committer: sig})
require.NoError(t, err)

// Second commit on HEAD (to prove we check out firstCommit, not HEAD)
require.NoError(t, os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# v2"), 0o644))
_, err = wt.Add("SKILL.md")
require.NoError(t, err)
_, err = wt.Commit("second", &git.CommitOptions{Author: sig, Committer: sig})
require.NoError(t, err)

dest := t.TempDir()
err = CloneGitCommit(context.Background(), src, firstCommit.String(), dest)
require.NoError(t, err)

content, err := os.ReadFile(filepath.Join(dest, "SKILL.md"))
require.NoError(t, err)
require.Equal(t, "# v1", string(content))
}

func TestCloneGitCommitHonorsContextTimeout(t *testing.T) {
unblock := make(chan struct{})

// Simulate a git server that accepts the connection and goes silent (#3452)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-unblock:
case <-r.Context().Done():
}
}))
defer srv.Close()
defer close(unblock)

ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()

err := CloneGitCommit(ctx, srv.URL+"/repo.git", strings.Repeat("a", 40), t.TempDir())
require.ErrorIs(t, err, context.DeadlineExceeded)
}
2 changes: 1 addition & 1 deletion go/core/pkg/agentplugins/materialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ func fetchSource(ctx context.Context, source agentplugin.Source, destination str
}
switch {
case source.Git != nil:
if err := skillsinit.CloneGitCommit(source.Git.URL, source.Git.Commit, destination); err != nil {
if err := skillsinit.CloneGitCommit(ctx, source.Git.URL, source.Git.Commit, destination); err != nil {
return "", err
}
case source.S3 != nil:
Expand Down
46 changes: 32 additions & 14 deletions go/core/pkg/agentplugins/materialize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"

"github.com/kagent-dev/kagent/go/api/agentplugin"
)
Expand All @@ -16,17 +19,17 @@ import (
func commitFiles(t *testing.T, files map[string]string) (repository, commit string) {
t.Helper()
repository = t.TempDir()
git := func(args ...string) string {
command := exec.Command("git", append([]string{"-C", repository}, args...)...)
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, output)
}
return strings.TrimSpace(string(output))

repo, err := git.PlainInit(repository, false)
if err != nil {
t.Fatalf("git init: %v", err)
}

wt, err := repo.Worktree()
if err != nil {
t.Fatalf("git worktree: %v", err)
}
git("init")
git("config", "user.email", "test@example.com")
git("config", "user.name", "Test")

for name, content := range files {
path := filepath.Join(repository, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
Expand All @@ -35,10 +38,25 @@ func commitFiles(t *testing.T, files map[string]string) (repository, commit stri
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if _, err := wt.Add(name); err != nil {
t.Fatalf("git add %s: %v", name, err)
}
}
git("add", ".")
git("commit", "-m", "plugin")
return repository, git("rev-parse", "HEAD")

sig := &object.Signature{
Name: "Test",
Email: "test@example.com",
When: time.Unix(1700000000, 0),
}
hash, err := wt.Commit("plugin", &git.CommitOptions{
Author: sig,
Committer: sig,
})
if err != nil {
t.Fatalf("git commit: %v", err)
}

return repository, hash.String()
}

func TestMaterializeGitPlugin(t *testing.T) {
Expand Down
15 changes: 15 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ require (
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.9
github.com/glebarez/sqlite v1.11.0
github.com/go-git/go-billy/v5 v5.9.2
github.com/go-git/go-git/v5 v5.19.3
github.com/go-logr/logr v1.4.4
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/go-containerregistry v0.22.1
Expand Down Expand Up @@ -125,6 +127,7 @@ require (
github.com/Microsoft/go-winio v0.6.3 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
Expand Down Expand Up @@ -178,6 +181,7 @@ require (
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
Expand All @@ -187,6 +191,7 @@ require (
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
Expand All @@ -202,6 +207,7 @@ require (
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260731231718-6c0b035a1609 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260819172001-e6e3fd93e4be // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
Expand All @@ -218,6 +224,7 @@ require (
github.com/ghostiam/protogetter v0.3.21 // indirect
github.com/glebarez/go-sqlite v1.23.0 // indirect
github.com/go-critic/go-critic v0.14.4 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
Expand Down Expand Up @@ -249,6 +256,7 @@ require (
github.com/goccy/go-json v0.11.2 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
Expand Down Expand Up @@ -289,13 +297,15 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.3 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jgautheron/goconst v1.11.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/kisielk/errcheck v1.20.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/klauspost/compress v1.20.3 // indirect
Expand Down Expand Up @@ -360,6 +370,7 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pjbgf/sha1cd v0.7.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240409071808-615f978279ca // indirect
Expand Down Expand Up @@ -391,11 +402,13 @@ require (
github.com/securego/gosec/v2 v2.28.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sethvargo/go-retry v0.5.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.7 // indirect
github.com/shopspring/decimal v1.5.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
Expand Down Expand Up @@ -423,6 +436,7 @@ require (
github.com/uudashr/iface v1.5.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
Expand Down Expand Up @@ -475,6 +489,7 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gorm.io/gorm v1.31.2 // indirect
honnef.co/go/tools v0.8.1 // indirect
istio.io/api v1.31.0-alpha.1.0.20260819121012-5803fb6accf7 // indirect
Expand Down
Loading