Skip to content
Closed
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
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
module github.com/git-hulk/clime

go 1.25
go 1.25.0

require (
github.com/alperdrsnn/clime v1.1.2
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.12.1
golang.org/x/mod v0.40.0
golang.org/x/term v0.33.0
gopkg.in/yaml.v3 v3.0.1
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUm
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
Expand Down
202 changes: 202 additions & 0 deletions internal/skill/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package skill

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

"golang.org/x/mod/semver"
)

// runGit executes git with prompts disabled and returns stdout. Stderr is
// credential-sanitized before it is attached to an error. It is a variable so
// tests can observe or disable network access.
var runGit = func(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("git %s failed: %w: %s",
args[0], err, strings.TrimSpace(SanitizeCredentials(stderr.String())))
}
return stdout.String(), nil
}

type remoteRefs struct {
headSHA string
defaultBranch string
branches map[string]string
tags map[string]string
}

// lsRemote queries the remote's refs and default branch over Git transport.
func lsRemote(id RepoID) (*remoteRefs, error) {
out, err := runGit("", "ls-remote", "--symref", id.CloneURL())
if err != nil {
return nil, fmt.Errorf("failed to query %s: %w", id.Canonical(), err)
}
refs := &remoteRefs{branches: map[string]string{}, tags: map[string]string{}}
for line := range strings.SplitSeq(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
if fields[0] == "ref:" && fields[len(fields)-1] == "HEAD" {
refs.defaultBranch = strings.TrimPrefix(fields[1], "refs/heads/")
continue
}
sha, ref := fields[0], fields[1]
switch {
case ref == "HEAD":
refs.headSHA = sha
case strings.HasPrefix(ref, "refs/heads/"):
refs.branches[strings.TrimPrefix(ref, "refs/heads/")] = sha
case strings.HasPrefix(ref, "refs/tags/"):
name := strings.TrimPrefix(ref, "refs/tags/")
// A peeled entry (tag^{}) points at the commit; prefer it.
if peeled, ok := strings.CutSuffix(name, "^{}"); ok {
refs.tags[peeled] = sha
} else if _, ok := refs.tags[name]; !ok {
refs.tags[name] = sha
}
}
}
return refs, nil
}

// latestStableTag returns the highest stable SemVer tag name, or "" when the
// repository has no stable SemVer tags. Tags with and without a leading "v"
// are both recognized; prereleases and build-metadata tags are skipped.
func latestStableTag(tags map[string]string) string {
var best, bestCanon string
for name := range tags {
canon := name
if !strings.HasPrefix(canon, "v") {
canon = "v" + canon
}
if !semver.IsValid(canon) || semver.Prerelease(canon) != "" || semver.Build(canon) != "" {
continue
}
if best == "" || semver.Compare(canon, bestCanon) > 0 {
best, bestCanon = name, canon
}
}
return best
}

// ResolveVersion resolves a user-supplied version spec against the remote to
// a locked version: a tag name kept verbatim, or a full commit SHA. An empty
// spec or "latest" selects the highest stable SemVer tag, falling back to the
// default branch's HEAD commit when the repository has no stable SemVer tags.
func ResolveVersion(id RepoID, spec string) (string, error) {
if IsFullCommit(spec) {
return spec, nil
}
refs, err := lsRemote(id)
if err != nil {
return "", err
}
if spec == "" || spec == "latest" {
if tag := latestStableTag(refs.tags); tag != "" {
return tag, nil
}
if refs.headSHA == "" {
return "", fmt.Errorf("repository %s has no stable SemVer tag and no HEAD commit", id.Canonical())
}
return refs.headSHA, nil
}
if _, ok := refs.tags[spec]; ok {
return spec, nil
}
if sha, ok := refs.branches[spec]; ok {
return sha, nil
}
if looksLikeShortCommit(spec) {
return expandShortCommit(id, spec)
}
return "", fmt.Errorf("version %q not found in %s: no matching tag, branch, or commit", spec, id.Canonical())
}

// expandShortCommit resolves a short commit hash to a full SHA by fetching
// history metadata into a throwaway repository.
func expandShortCommit(id RepoID, short string) (string, error) {
tmp, err := os.MkdirTemp("", "clime-resolve-*")
if err != nil {
return "", err
}
defer os.RemoveAll(tmp)

if _, err := runGit(tmp, "init", "-q", "--bare"); err != nil {
return "", err
}
if _, err := runGit(tmp, "remote", "add", "origin", id.CloneURL()); err != nil {
return "", err
}
refspecs := []string{"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
// A treeless fetch keeps this cheap; fall back for hosts without
// partial-clone support.
if _, err := runGit(tmp, append([]string{"fetch", "-q", "--filter=tree:0", "origin"}, refspecs...)...); err != nil {
if _, err := runGit(tmp, append([]string{"fetch", "-q", "origin"}, refspecs...)...); err != nil {
return "", fmt.Errorf("failed to resolve commit %q in %s: %w", short, id.Canonical(), err)
}
}
out, err := runGit(tmp, "rev-parse", "--verify", short+"^{commit}")
if err != nil {
return "", fmt.Errorf("commit %q not found in %s: %w", short, id.Canonical(), err)
}
return strings.TrimSpace(out), nil
}

func initRemote(dir string, id RepoID) error {
if _, err := runGit(dir, "init", "-q"); err != nil {
return err
}
_, err := runGit(dir, "remote", "add", "origin", id.CloneURL())
return err
}

// fetchSnapshot checks out the given locked version (tag or full commit SHA)
// into dir and strips Git metadata, returning the resolved commit SHA.
func fetchSnapshot(id RepoID, version, dir string) (string, error) {
if err := initRemote(dir, id); err != nil {
return "", err
}
if IsFullCommit(version) {
if _, err := runGit(dir, "fetch", "-q", "--depth", "1", "origin", version); err != nil {
// Some hosts refuse fetching by SHA; fall back to a full
// branch fetch and check out the commit from history.
if _, ferr := runGit(dir, "fetch", "-q", "origin", "+refs/heads/*:refs/remotes/origin/*"); ferr != nil {
return "", fmt.Errorf("failed to fetch %s@%s: %w", id.Canonical(), version, err)
}
if _, cerr := runGit(dir, "checkout", "-q", "--detach", version); cerr != nil {
return "", fmt.Errorf("failed to fetch %s@%s: %w", id.Canonical(), version, err)
}
return finishSnapshot(dir)
}
} else {
if _, err := runGit(dir, "fetch", "-q", "--depth", "1", "origin", "refs/tags/"+version); err != nil {
return "", fmt.Errorf("failed to fetch %s@%s: %w", id.Canonical(), version, err)
}
}
if _, err := runGit(dir, "checkout", "-q", "--detach", "FETCH_HEAD"); err != nil {
return "", fmt.Errorf("failed to check out %s@%s: %w", id.Canonical(), version, err)
}
return finishSnapshot(dir)
}

func finishSnapshot(dir string) (string, error) {
out, err := runGit(dir, "rev-parse", "HEAD")
if err != nil {
return "", err
}
if err := os.RemoveAll(filepath.Join(dir, ".git")); err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
130 changes: 130 additions & 0 deletions internal/skill/git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package skill

import (
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const fixtureRepo = "test/skills"

func TestResolveVersionLatestSelectsHighestStableSemver(t *testing.T) {
dir := initSkillFixture(t, "a-skill")
gitCmd(t, dir, "tag", "v1.0.0")
gitCmd(t, dir, "tag", "v1.2.0")
gitCmd(t, dir, "tag", "v1.10.0")
gitCmd(t, dir, "tag", "v2.0.0-rc.1")
gitCmd(t, dir, "tag", "banana")
routeRepos(t, map[string]string{fixtureRepo: dir})

id, _ := ParseRepo(fixtureRepo)
for _, spec := range []string{"", "latest"} {
version, err := ResolveVersion(id, spec)
require.NoError(t, err, "ResolveVersion(%q)", spec)
assert.Equal(t, "v1.10.0", version, "ResolveVersion(%q)", spec)
}
}

func TestResolveVersionLatestWithoutSemverTagsUsesHeadSHA(t *testing.T) {
dir := initSkillFixture(t, "a-skill")
gitCmd(t, dir, "tag", "release-1")
routeRepos(t, map[string]string{fixtureRepo: dir})
head := gitCmd(t, dir, "rev-parse", "HEAD")

id, _ := ParseRepo(fixtureRepo)
version, err := ResolveVersion(id, "latest")
require.NoError(t, err)
assert.Equal(t, head, version, "latest should lock to the default branch HEAD")
assert.True(t, IsFullCommit(version), "version %q is not a full commit SHA", version)
}

func TestResolveVersionBranchLocksToFullSHA(t *testing.T) {
dir := initSkillFixture(t, "a-skill")
gitCmd(t, dir, "checkout", "-q", "-b", "dev")
writeSkillFixtureContent(t, dir, "v2", "a-skill")
gitCmd(t, dir, "add", "-A")
gitCmd(t, dir, "commit", "-q", "-m", "dev change")
devHead := gitCmd(t, dir, "rev-parse", "HEAD")
gitCmd(t, dir, "checkout", "-q", "main")
routeRepos(t, map[string]string{fixtureRepo: dir})

id, _ := ParseRepo(fixtureRepo)
version, err := ResolveVersion(id, "dev")
require.NoError(t, err)
assert.Equal(t, devHead, version)
}

func TestResolveVersionShortCommitExpandsToFullSHA(t *testing.T) {
dir := initSkillFixture(t, "a-skill")
routeRepos(t, map[string]string{fixtureRepo: dir})
head := gitCmd(t, dir, "rev-parse", "HEAD")

id, _ := ParseRepo(fixtureRepo)
version, err := ResolveVersion(id, head[:8])
require.NoError(t, err)
assert.Equal(t, head, version)
}

func TestResolveVersionExplicitTagKeptVerbatim(t *testing.T) {
dir := initSkillFixture(t, "a-skill")
gitCmd(t, dir, "tag", "v2.0.0-rc.1")
routeRepos(t, map[string]string{fixtureRepo: dir})

id, _ := ParseRepo(fixtureRepo)
// A prerelease is selected only when requested explicitly.
version, err := ResolveVersion(id, "v2.0.0-rc.1")
require.NoError(t, err)
assert.Equal(t, "v2.0.0-rc.1", version)
}

func TestResolveVersionFullSHAPassesThroughWithoutNetwork(t *testing.T) {
disableGit(t)
id, _ := ParseRepo(fixtureRepo)
sha := strings.Repeat("ab12", 10)
version, err := ResolveVersion(id, sha)
require.NoError(t, err)
assert.Equal(t, sha, version)
}

func TestResolveVersionUnknownSpecFails(t *testing.T) {
dir := initSkillFixture(t, "a-skill")
routeRepos(t, map[string]string{fixtureRepo: dir})

id, _ := ParseRepo(fixtureRepo)
_, err := ResolveVersion(id, "no-such-thing")
assert.Error(t, err, "unknown version spec should fail")
}

func TestFetchSnapshotByTagAndBySHA(t *testing.T) {
dir := initSkillFixture(t, "a-skill")
gitCmd(t, dir, "tag", "v1.0.0")
routeRepos(t, map[string]string{fixtureRepo: dir})
head := gitCmd(t, dir, "rev-parse", "HEAD")
id, _ := ParseRepo(fixtureRepo)

for _, version := range []string{"v1.0.0", head} {
tmp := t.TempDir()
commit, err := fetchSnapshot(id, version, tmp)
require.NoError(t, err, "fetchSnapshot(%q)", version)
assert.Equal(t, head, commit, "fetchSnapshot(%q) commit", version)
assert.FileExists(t, filepath.Join(tmp, "skills", "a-skill", "SKILL.md"))
assert.NoDirExists(t, filepath.Join(tmp, ".git"), "snapshot still contains .git metadata")
}
}

func TestGitErrorsAreCredentialFree(t *testing.T) {
orig := runGit
t.Cleanup(func() { runGit = orig })

// Simulate a git failure whose stderr leaks HTTPS userinfo, as the
// real runGit sanitizes stderr before wrapping it.
_, err := orig("", "ls-remote", "https://alice:hunter2@invalid.invalid/acme/skills.git")
if err == nil {
t.Skip("expected ls-remote against invalid host to fail")
}
assert.NotContains(t, err.Error(), "hunter2", "git error leaks credentials")
assert.NotContains(t, err.Error(), "alice:", "git error leaks credentials")
}
Loading
Loading