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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ vers commit
# Commit a specific VM
vers commit <vm-id>

# Commit + tag + publish in one shot
vers commit create <vm-id> --tag my-app:v1.2 --tag my-app:latest --public
# --tag <repo>:<tag> (repeatable) creates the tag, or updates an existing one
# to point at the new commit
# --public publishes the new commit (is_public=true)

# List your commits
vers commit list
vers commit list -q # just IDs
Expand Down
23 changes: 11 additions & 12 deletions cmd/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ var (
commitFormat string
commitName string
commitDescription string
commitTags []string
commitPublic bool
)

// commitCmd is the parent command for commit operations.
Expand Down Expand Up @@ -49,12 +51,15 @@ If no VM ID or alias is provided, commits the current HEAD VM.

Use --name to give the commit a human-readable name.
Use --description to add additional context.
Use --tag <repo>:<tag> (repeatable) to create or update repo tags pointing at the new commit.
Use --public to publish the commit (set is_public=true) after it lands.
Use --json for machine-readable output.

Examples:
vers commit create --name "golden-image-v3"
vers commit create --name "pre-deploy" --description "Before deploying auth changes"
vers commit create vm-123 --name "checkpoint"`,
vers commit create vm-123 --name "checkpoint"
vers commit create vm-123 --tag my-app:v1.2 --tag my-app:latest --public`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
target := ""
Expand All @@ -69,6 +74,8 @@ Examples:
Target: target,
Name: commitName,
Description: commitDescription,
Tags: commitTags,
Public: commitPublic,
})
if err != nil {
return err
Expand All @@ -82,17 +89,7 @@ Examples:
case pres.FormatJSON:
pres.PrintJSON(res)
default:
if res.UsedHEAD {
fmt.Printf("Using current HEAD VM: %s\n", res.VmID)
}
fmt.Printf("Committed VM '%s'\n", res.VmID)
fmt.Printf("Commit ID: %s\n", res.CommitID)
if res.Name != "" {
fmt.Printf("Name: %s\n", res.Name)
}
if res.Description != "" {
fmt.Printf("Description: %s\n", res.Description)
}
pres.RenderCommitCreate(application, res)
}
return nil
},
Expand Down Expand Up @@ -284,6 +281,8 @@ func init() {
_ = commitCreateCmd.Flags().MarkDeprecated("format", "use --json instead")
commitCreateCmd.Flags().StringVarP(&commitName, "name", "n", "", "Human-readable name for the commit")
commitCreateCmd.Flags().StringVarP(&commitDescription, "description", "d", "", "Description for the commit")
commitCreateCmd.Flags().StringSliceVar(&commitTags, "tag", nil, "Repo tag to write pointing at the new commit, in <repo>:<tag> form (repeatable)")
commitCreateCmd.Flags().BoolVar(&commitPublic, "public", false, "Publish the commit (set is_public=true) after it lands")
commitCmd.AddCommand(commitCreateCmd)

commitListCmd.Flags().BoolVar(&commitListPublic, "public", false, "List public commits instead of your own")
Expand Down
150 changes: 139 additions & 11 deletions internal/handlers/commit_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ package handlers

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"

"github.com/hdresearch/vers-cli/internal/app"
"github.com/hdresearch/vers-cli/internal/presenters"
"github.com/hdresearch/vers-cli/internal/utils"
vers "github.com/hdresearch/vers-sdk-go"
"github.com/hdresearch/vers-sdk-go/option"
Expand All @@ -14,41 +19,164 @@ type CommitCreateReq struct {
Target string
Name string
Description string
// Tags is a list of raw "<repo>:<tag>" references to write after the
// commit lands. Each entry creates the tag if it does not yet exist,
// or updates an existing tag to point at the new commit.
Tags []string
// Public, when true, publishes the new commit (sets is_public=true)
// after the commit and any tag writes succeed.
Public bool
}

type CommitCreateView struct {
CommitID string `json:"commit_id"`
VmID string `json:"vm_id"`
UsedHEAD bool `json:"used_head,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
// CommitCreateView is re-exported from the presenters package so callers
// can keep importing it from handlers as before.
type CommitCreateView = presenters.CommitCreateView

// CommitTagWritten is re-exported for the same reason.
type CommitTagWritten = presenters.CommitTagWritten

// parsed form of a single --tag value
type tagSpec struct {
reference string // original "repo:tag" input
repo string
tag string
}

func parseTagSpecs(raw []string) ([]tagSpec, error) {
specs := make([]tagSpec, 0, len(raw))
for _, s := range raw {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, fmt.Errorf("--tag must be in <repo>:<tag> form (got: %q)", s)
}
specs = append(specs, tagSpec{reference: s, repo: parts[0], tag: parts[1]})
}
return specs, nil
}

func HandleCommitCreate(ctx context.Context, a *app.App, r CommitCreateReq) (CommitCreateView, error) {
// 1. Validate --tag shape up-front, before any side effects.
specs, err := parseTagSpecs(r.Tags)
if err != nil {
return CommitCreateView{}, err
}

// 2. Verify every referenced repo exists and capture its visibility.
// Fail-fast before creating the commit if any repo is missing.
repoPublic := make(map[string]bool, len(specs))
for _, s := range specs {
if _, seen := repoPublic[s.repo]; seen {
continue
}
info, err := a.Client.Repositories.Get(ctx, s.repo)
if err != nil {
var apiErr *vers.Error
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
return CommitCreateView{}, fmt.Errorf("repo %q not found. Create it first with: vers repo create %s", s.repo, s.repo)
}
return CommitCreateView{}, fmt.Errorf("failed to look up repo %q: %w", s.repo, err)
}
repoPublic[s.repo] = info.IsPublic
}

// 3. Resolve target VM.
resolved, err := utils.ResolveTargetVM(ctx, a.Client, r.Target)
if err != nil {
return CommitCreateView{}, err
}

// Build request options to send name/description in the request body
// 4. Create the commit.
var opts []option.RequestOption
if r.Name != "" {
opts = append(opts, option.WithJSONSet("name", r.Name))
}
if r.Description != "" {
opts = append(opts, option.WithJSONSet("description", r.Description))
}

resp, err := a.Client.Vm.Commit(ctx, resolved.ID, vers.VmCommitParams{}, opts...)
if err != nil {
return CommitCreateView{}, fmt.Errorf("failed to commit VM '%s': %w", resolved.ID, err)
}
commitID := resp.CommitID

return CommitCreateView{
CommitID: resp.CommitID,
view := presenters.CommitCreateView{
CommitID: commitID,
VmID: resolved.ID,
UsedHEAD: resolved.UsedHEAD,
Name: r.Name,
Description: r.Description,
}, nil
}

// 5. Write tags. Any failure here returns an error that names the new
// commit ID so the user can recover by hand.
for _, s := range specs {
existing, getErr := a.Client.Repositories.GetTag(ctx, s.repo, s.tag)
var apiErr *vers.Error
switch {
case getErr == nil && existing != nil:
// Tag exists -> update it to point at the new commit.
updErr := HandleRepoTagUpdate(ctx, a, RepoTagUpdateReq{
RepoName: s.repo,
TagName: s.tag,
CommitID: commitID,
})
if updErr != nil {
return view, fmt.Errorf("commit %s created, but failed to update tag %s: %w", commitID, s.reference, updErr)
}
view.TagsWritten = append(view.TagsWritten, presenters.CommitTagWritten{
Reference: s.reference,
TagID: existing.TagID,
})
case errors.As(getErr, &apiErr) && apiErr.StatusCode == http.StatusNotFound:
// Tag does not exist -> create it pointing at the new commit.
created, createErr := HandleRepoTagCreate(ctx, a, RepoTagCreateReq{
RepoName: s.repo,
TagName: s.tag,
CommitID: commitID,
})
if createErr != nil {
return view, fmt.Errorf("commit %s created, but failed to create tag %s: %w", commitID, s.reference, createErr)
}
view.TagsWritten = append(view.TagsWritten, presenters.CommitTagWritten{
Reference: s.reference,
TagID: created.TagID,
})
default:
return view, fmt.Errorf("commit %s created, but failed to look up tag %s: %w", commitID, s.reference, getErr)
}
}

// 6. Publish if requested.
if r.Public {
info, pubErr := HandleCommitUpdate(ctx, a, CommitUpdateReq{
CommitID: commitID,
IsPublic: true,
})
if pubErr != nil {
return view, fmt.Errorf("commit %s created (tags written: %d), but failed to publish: %w", commitID, len(view.TagsWritten), pubErr)
}
view.IsPublic = info.IsPublic
}

// 7. Visibility-mismatch warning: any tag target repo is public but
// the commit was not explicitly published. Stderr only — do not
// auto-publish (the conservative recommendation from #201).
if !r.Public {
var publicRepos []string
for repo, pub := range repoPublic {
if pub {
publicRepos = append(publicRepos, repo)
}
}
if len(publicRepos) > 0 {
out := a.IO.Err
if out == nil {
out = io.Discard
}
fmt.Fprintf(out, "warning: commit %s was tagged into public repo(s) %s but was not published. The tag references a private commit and will not be reachable. Re-run with --public next time, or run: vers commit publish %s\n",
commitID, strings.Join(publicRepos, ", "), commitID)
}
}

return view, nil
}
Loading
Loading