diff --git a/commands.go b/commands.go index bd10a33..5f1b473 100644 --- a/commands.go +++ b/commands.go @@ -918,12 +918,16 @@ func Update(ctx context.Context) ([]UpdateCheck, error) { continue } - ref := u.Entry.Source.Ref - if ref == "" { - ref = "main" + ref, err := resolveCloneRef(ctx, "https://github.com/"+owner+"/"+repo+".git", u.Entry.Source.Ref) + if err != nil { + continue } - srcURL := "https://github.com/" + owner + "/" + repo + "/tree/" + ref + "/" + strings.TrimPrefix(u.Entry.SkillPath, "/") + srcURL := "https://github.com/" + owner + "/" + repo + if ref != "" { + srcURL += "/tree/" + ref + } + srcURL += "/" + strings.TrimPrefix(u.Entry.SkillPath, "/") _, _ = Add(ctx, AddOptions{ Source: srcURL, Global: true, diff --git a/git.go b/git.go index d0785d7..bc6e6b9 100644 --- a/git.go +++ b/git.go @@ -9,6 +9,8 @@ import ( "strings" ) +var ErrDefaultBranchNotFound = errors.New("default branch not found") + type clonedRepo struct { Dir string } @@ -23,6 +25,26 @@ func cloneRepo(ctx context.Context, src SkillSource) (*clonedRepo, error) { _ = os.RemoveAll(tmp) } + repoURL := normalizeRepoURL(src) + + args := []string{"clone", "--depth", "1"} + if src.Ref != "" { + args = append(args, "--branch", src.Ref) + } + args = append(args, repoURL, tmp) + + cmd := exec.CommandContext(ctx, "git", args...) + + out, err := cmd.CombinedOutput() + if err != nil { + cleanup() + return nil, errors.New("git clone failed: " + strings.TrimSpace(string(out))) + } + + return &clonedRepo{Dir: tmp}, nil +} + +func normalizeRepoURL(src SkillSource) string { repoURL := src.SourceURL if src.Type == SkillSourceTypeGitHub && src.Owner != "" && src.Repo != "" && !strings.HasPrefix(repoURL, "http") { repoURL = "https://github.com/" + src.Owner + "/" + src.Repo + ".git" @@ -36,21 +58,46 @@ func cloneRepo(ctx context.Context, src SkillSource) (*clonedRepo, error) { repoURL = repoURL + ".git" } - ref := src.Ref - if ref == "" { - ref = "main" + return repoURL +} + +func resolveCloneRef(ctx context.Context, repoURL string, requestedRef string) (string, error) { + if requestedRef != "" { + return requestedRef, nil } - args := []string{"clone", "--depth", "1", "--branch", ref, repoURL, tmp} - cmd := exec.CommandContext(ctx, "git", args...) + ref, err := detectDefaultBranch(ctx, repoURL) + if err != nil { + if errors.Is(err, ErrDefaultBranchNotFound) { + return "", nil + } + return "", err + } + + return ref, nil +} +func detectDefaultBranch(ctx context.Context, repoURL string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "ls-remote", "--symref", repoURL, "HEAD") out, err := cmd.CombinedOutput() if err != nil { - cleanup() - return nil, errors.New("git clone failed: " + strings.TrimSpace(string(out))) + return "", errors.New("git ls-remote failed: " + strings.TrimSpace(string(out))) } - return &clonedRepo{Dir: tmp}, nil + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "ref:") || !strings.Contains(line, "\tHEAD") { + continue + } + + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + return strings.TrimPrefix(fields[1], "refs/heads/"), nil + } + + return "", ErrDefaultBranchNotFound } func (r *clonedRepo) ResolveSubdir(subpath string) (string, error) { diff --git a/git_test.go b/git_test.go new file mode 100644 index 0000000..e9416e4 --- /dev/null +++ b/git_test.go @@ -0,0 +1,43 @@ +package skills + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestDetectDefaultBranch(t *testing.T) { + tmp := t.TempDir() + remote := filepath.Join(tmp, "remote.git") + work := filepath.Join(tmp, "work") + + runCmd(t, tmp, "git", "init", "--bare", remote) + runCmd(t, tmp, "git", "clone", remote, work) + runCmd(t, work, "git", "checkout", "-b", "master") + runCmd(t, work, "git", "config", "user.name", "tester") + runCmd(t, work, "git", "config", "user.email", "tester@example.com") + runCmd(t, work, "git", "commit", "--allow-empty", "-m", "init") + runCmd(t, work, "git", "push", "-u", "origin", "master") + + branch, err := detectDefaultBranch(context.Background(), remote) + if err != nil { + t.Fatalf("detectDefaultBranch returned error: %v", err) + } + + if branch != "master" { + t.Fatalf("expected master, got %q", branch) + } +} + +func runCmd(t *testing.T, dir string, name string, args ...string) { + t.Helper() + + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, out) + } +} diff --git a/source.go b/source.go index 42eef91..70d09de 100644 --- a/source.go +++ b/source.go @@ -58,7 +58,6 @@ func ParseSource(input string) (SkillSource, error) { SourceURL: in, Owner: parts[0], Repo: strings.TrimSuffix(parts[1], ".git"), - Ref: "main", }, nil } } @@ -111,7 +110,6 @@ func ParseSource(input string) (SkillSource, error) { SourceURL: "https://github.com/" + repoPart, Owner: owner, Repo: repo, - Ref: "main", SkillFilter: skillFilter, }, nil } diff --git a/source_test.go b/source_test.go index 350557e..192cb79 100644 --- a/source_test.go +++ b/source_test.go @@ -32,6 +32,21 @@ func TestParseSource_GitHubShorthand(t *testing.T) { if src.Owner != "vercel-labs" || src.Repo != "agent-skills" { t.Fatalf("owner/repo mismatch: %s/%s", src.Owner, src.Repo) } + + if src.Ref != "" { + t.Fatalf("expected empty ref for default branch discovery, got %q", src.Ref) + } +} + +func TestParseSource_GitHubRepoURL(t *testing.T) { + src, err := ParseSource("https://github.com/a/b") + if err != nil { + t.Fatalf("expected nil err, got %v", err) + } + + if src.Type != SkillSourceTypeGitHub || src.Ref != "" { + t.Fatalf("expected github source with empty ref, got %#v", src) + } } func TestParseSource_GitHubTreeURL(t *testing.T) {