diff --git a/pkg/cmd/template_download.go b/pkg/cmd/template_download.go index d9c63fe..a97dffa 100644 --- a/pkg/cmd/template_download.go +++ b/pkg/cmd/template_download.go @@ -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 } diff --git a/pkg/cmd/template_list.go b/pkg/cmd/template_list.go index 6695abb..c384d98 100644 --- a/pkg/cmd/template_list.go +++ b/pkg/cmd/template_list.go @@ -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) diff --git a/pkg/cmd/template_list_test.go b/pkg/cmd/template_list_test.go index 9e04f56..0886e01 100644 --- a/pkg/cmd/template_list_test.go +++ b/pkg/cmd/template_list_test.go @@ -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" ) @@ -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) } @@ -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") diff --git a/pkg/cmd/template_update.go b/pkg/cmd/template_update.go index dfb18b2..a53c7ed 100644 --- a/pkg/cmd/template_update.go +++ b/pkg/cmd/template_update.go @@ -38,6 +38,7 @@ func newTemplateUpdateCmd(app *App) *cobra.Command { networkErrorSeen := false updatesAvailable := []string{} + checkedCount := 0 for _, name := range names { root := specs.TemplatePath(name) @@ -45,12 +46,27 @@ func newTemplateUpdateCmd(app *App) *cobra.Command { 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()}, @@ -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 diff --git a/pkg/cmd/template_update_test.go b/pkg/cmd/template_update_test.go index 5a7a0b9..015cb78 100644 --- a/pkg/cmd/template_update_test.go +++ b/pkg/cmd/template_update_test.go @@ -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) + } +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index fbb4974..3986de3 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -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 } @@ -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 diff --git a/pkg/util/git/describe_test.go b/pkg/util/git/describe_test.go index 1623200..2d2e814 100644 --- a/pkg/util/git/describe_test.go +++ b/pkg/util/git/describe_test.go @@ -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") diff --git a/pkg/util/git/git.go b/pkg/util/git/git.go index faf59f3..8b908e1 100644 --- a/pkg/util/git/git.go +++ b/pkg/util/git/git.go @@ -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