Skip to content
Merged
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
12 changes: 11 additions & 1 deletion pkg/cmd/template_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,19 @@ func newTemplateDownloadCmd(app *App) *cobra.Command {
if err := pkggit.Clone(src.CloneURL, dest, pkggit.CloneOptions{Branch: src.Branch}); err != nil {
return err
}

// When no branch was specified, resolve the actual checked-out branch so that
// future update/upgrade checks can compare against the correct remote ref.
branch := src.Branch
if branch == "" {
if b, err := pkggit.CurrentBranch(dest); err == nil {
branch = b
}
}

// git layer logs describe result or failure
desc, _ := pkggit.Describe(dest)
if err := pkgtemplate.SaveMetadata(dest, name, src.CloneURL, src.Branch, desc.Commit, desc.Version, time.Now().UTC()); err != nil {
if err := pkgtemplate.SaveMetadata(dest, name, src.CloneURL, branch, desc.Commit, desc.Version, time.Now().UTC()); err != nil {
return err
}

Expand Down
10 changes: 10 additions & 0 deletions pkg/cmd/template_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ func newTemplateListCmd(app *App) *cobra.Command {
if err != nil {
slog.Debug("failed to parse template metadata", "template", name, "error", err)
}
// Resolve and persist the branch when metadata has none so that the
// status refresh below and hasRemote checks work correctly.
if meta != nil && meta.Repository != "" && meta.Branch == "" {
if b, err := pkggit.CurrentBranch(root); err == nil {
meta.Branch = b
if err := pkgtemplate.SaveMetadata(root, name, meta.Repository, b, meta.Commit, meta.Version, meta.Created.Time); err != nil {
slog.Debug("failed to persist resolved branch", "template", name, "error", err)
}
}
}
var status *pkgtemplate.TemplateStatus
if meta != nil && meta.Repository != "" && meta.Branch != "" {
status, err = pkgtemplate.LoadStatus(root)
Expand Down
53 changes: 52 additions & 1 deletion pkg/cmd/template_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"testing"
"time"

gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
pkgtemplate "github.com/specsnl/specs-cli/pkg/template"
pkggit "github.com/specsnl/specs-cli/pkg/util/git"
)
Expand Down Expand Up @@ -91,7 +93,8 @@ func TestList_StatusColumn_LocalNoStatus(t *testing.T) {
if err := os.MkdirAll(tmplDir, 0755); err != nil {
t.Fatal(err)
}
// Local template: repository set but no branch.
// Local template: repository set but no branch AND no git repo — branch
// resolution fails, so status stays "-".
if err := pkgtemplate.SaveMetadata(tmplDir, "local-tpl", "/local/path", "", "", "", time.Now().UTC()); err != nil {
t.Fatal(err)
}
Expand All @@ -105,6 +108,54 @@ func TestList_StatusColumn_LocalNoStatus(t *testing.T) {
}
}

func TestList_StatusColumn_EmptyBranchWithGitRepo(t *testing.T) {
registryDir := withTempRegistry(t)
tmplDir := filepath.Join(registryDir, "remote-tpl")
if err := os.MkdirAll(tmplDir, 0755); err != nil {
t.Fatal(err)
}

// Initialise a real git repo so CurrentBranch can resolve the branch.
repo, err := gogit.PlainInit(tmplDir, false)
if err != nil {
t.Fatalf("PlainInit: %v", err)
}
wt, _ := repo.Worktree()
if err := os.WriteFile(filepath.Join(tmplDir, "f.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if _, err := wt.Add("f.txt"); err != nil {
t.Fatal(err)
}
sig := &object.Signature{Name: "t", Email: "t@t.com", When: time.Now()}
if _, err := wt.Commit("init", &gogit.CommitOptions{Author: sig}); err != nil {
t.Fatal(err)
}

// Save metadata with empty Branch — simulates a template downloaded without
// an explicit branch specification.
if err := pkgtemplate.SaveMetadata(tmplDir, "remote-tpl", "https://example.com/repo", "", "", "", time.Now().UTC()); err != nil {
t.Fatal(err)
}

out, err := executeCmdWithCheckFn(
func(_ context.Context, _, _, _ string) pkggit.RemoteCheckResult {
return pkggit.RemoteCheckResult{IsUpToDate: true}
},
"template", "list", "--output=json",
)
if err != nil {
t.Fatalf("template list: %v", err)
}
// Branch was resolved from local HEAD so the check ran — status must not be "-".
if strings.Contains(out, `"Status":"-"`) {
t.Errorf("expected non-dash status when branch resolved from git HEAD, got: %q", out)
}
if !strings.Contains(out, "up-to-date") {
t.Errorf("expected 'up-to-date' in output, got: %q", out)
}
}

func TestList_StatusColumn_FreshUpToDate(t *testing.T) {
registryDir := withTempRegistry(t)
tmplDir := filepath.Join(registryDir, "remote-tpl")
Expand Down
28 changes: 24 additions & 4 deletions pkg/cmd/template_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,35 @@ func newTemplateUpdateCmd(app *App) *cobra.Command {

networkErrorSeen := false
updatesAvailable := []string{}
checkedCount := 0

for _, name := range names {
root := specs.TemplatePath(name)
meta, err := pkgtemplate.LoadMetadata(root)
if err != nil {
slog.Debug("failed to parse template metadata", "template", name, "error", err)
}
if meta == nil || meta.Repository == "" || meta.Branch == "" {
if meta == nil || meta.Repository == "" {
continue
}

branch := meta.Branch
if branch == "" {
b, err := pkggit.CurrentBranch(root)
if err != nil {
slog.Debug("could not resolve branch from local HEAD, skipping", "template", name, "error", err)
continue
}
branch = b
// Persist the resolved branch so future runs skip this fallback.
if err := pkgtemplate.SaveMetadata(root, name, meta.Repository, branch, meta.Commit, meta.Version, meta.Created.Time); err != nil {
slog.Debug("failed to persist resolved branch", "template", name, "error", err)
}
}

checkedCount++
// git layer logs the check-remote start/result
result := pkggit.CheckRemote(root, meta.Repository, meta.Branch)
result := pkggit.CheckRemote(root, meta.Repository, branch)

newStatus := &pkgtemplate.TemplateStatus{
CheckedAt: pkgtemplate.JSONTime{Time: time.Now().UTC()},
Expand Down Expand Up @@ -95,8 +111,12 @@ func newTemplateUpdateCmd(app *App) *cobra.Command {
app.Output.Info("template %q has an update available", name)
}
}
} else if !networkErrorSeen {
app.Output.Info("all templates are up-to-date")
} else if !networkErrorSeen && checkedCount > 0 {
if len(names) == 1 {
app.Output.Info("template %q is up-to-date", names[0])
} else {
app.Output.Info("all templates are up-to-date")
}
}

return nil
Expand Down
33 changes: 33 additions & 0 deletions pkg/cmd/template_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,36 @@ func TestUpdate_TooManyArgs(t *testing.T) {
t.Fatal("expected error when too many args given")
}
}

func TestUpdate_NamedLocalTemplate_ProducesNoOutput(t *testing.T) {
registryDir := withTempRegistry(t)

// A template with a local Repository but no Branch — silently skipped.
tmplDir := filepath.Join(registryDir, "local-tpl")
if err := os.MkdirAll(tmplDir, 0755); err != nil {
t.Fatal(err)
}
if err := pkgtemplate.SaveMetadata(tmplDir, "local-tpl", "/some/local/path", "", "", "", time.Now().UTC()); err != nil {
t.Fatal(err)
}

out, err := executeCmd("template", "update", "local-tpl")
if err != nil {
t.Fatalf("template update local-tpl: %v", err)
}
if out != "" {
t.Errorf("expected no output for local/skipped template, got: %q", out)
}
}

func TestUpdate_NoArgs_EmptyRegistry_ProducesNoOutput(t *testing.T) {
withTempRegistry(t)

out, err := executeCmd("template", "update")
if err != nil {
t.Fatalf("template update with empty registry: %v", err)
}
if out != "" {
t.Errorf("expected no output for empty registry, got: %q", out)
}
}
18 changes: 14 additions & 4 deletions pkg/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,22 @@ func Upgrade(name string) (UpgradeResult, error) {
if err != nil {
slog.Debug("failed to parse template metadata", "template", name, "error", err)
}
if meta == nil || meta.Repository == "" || meta.Branch == "" {
if meta == nil || meta.Repository == "" {
return UpgradeResult{IsLocal: true}, nil
}

targetRef := meta.Branch
result := pkggit.CheckRemote(root, meta.Repository, meta.Branch)
branch := meta.Branch
if branch == "" {
b, err := pkggit.CurrentBranch(root)
if err != nil {
slog.Debug("could not resolve branch from local HEAD, treating as local", "template", name, "error", err)
return UpgradeResult{IsLocal: true}, nil
}
branch = b
}

targetRef := branch
result := pkggit.CheckRemote(root, meta.Repository, branch)
if err := result.Err(); err != nil {
return UpgradeResult{}, err
}
Expand All @@ -91,7 +101,7 @@ func Upgrade(name string) (UpgradeResult, error) {
"latest_version", result.LatestVersion,
)

newBranch := meta.Branch
newBranch := branch
if result.LatestVersion != "" {
targetRef = result.LatestVersion
newBranch = result.LatestVersion
Expand Down
40 changes: 40 additions & 0 deletions pkg/util/git/describe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,46 @@ func tagCommit(t *testing.T, repo *gogit.Repository, name string, hash plumbing.
}
}

func TestCurrentBranch_OnBranch(t *testing.T) {
dir, repo := initRepo(t)
addCommit(t, repo, dir, "init")

got, err := pkggit.CurrentBranch(dir)
if err != nil {
t.Fatalf("CurrentBranch: %v", err)
}
if got == "" {
t.Error("CurrentBranch: expected non-empty branch name")
}
}

func TestCurrentBranch_DetachedHead(t *testing.T) {
dir, repo := initRepo(t)
hash := addCommit(t, repo, dir, "init")

// Detach HEAD by checking out a commit hash directly.
wt, err := repo.Worktree()
if err != nil {
t.Fatalf("Worktree: %v", err)
}
if err := wt.Checkout(&gogit.CheckoutOptions{Hash: hash}); err != nil {
t.Fatalf("Checkout (detach): %v", err)
}

_, err = pkggit.CurrentBranch(dir)
if err == nil {
t.Error("CurrentBranch: expected error for detached HEAD, got nil")
}
}

func TestCurrentBranch_NotARepo(t *testing.T) {
_, err := pkggit.CurrentBranch(t.TempDir())
if err == nil {
t.Error("CurrentBranch: expected error for non-repo directory, got nil")
}
}


func TestDescribe_ExactLightweightTag(t *testing.T) {
dir, repo := initRepo(t)
hash := addCommit(t, repo, dir, "init")
Expand Down
18 changes: 18 additions & 0 deletions pkg/util/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,24 @@ func sshAuth(url string) (transport.AuthMethod, error) {
return nil, fmt.Errorf("no SSH authentication available: SSH agent not running and no usable key file found in ~/.ssh")
}

// CurrentBranch returns the short name of the currently checked-out branch in dir.
// Returns an error when dir is not a git repository, HEAD cannot be read, or HEAD is in a
// detached state (e.g. a tag checkout).
func CurrentBranch(dir string) (string, error) {
repo, err := gogit.PlainOpen(dir)
if err != nil {
return "", fmt.Errorf("opening repository at %s: %w", dir, err)
}
head, err := repo.Head()
if err != nil {
return "", fmt.Errorf("reading HEAD: %w", err)
}
if !head.Name().IsBranch() {
return "", fmt.Errorf("HEAD is not on a branch (detached HEAD or tag checkout)")
}
return head.Name().Short(), nil
}

// CheckErrorKind classifies why a remote status check failed.
type CheckErrorKind string

Expand Down