diff --git a/cmd/commit.go b/cmd/commit.go index 2fbfb8d..5cea032 100644 --- a/cmd/commit.go +++ b/cmd/commit.go @@ -9,7 +9,11 @@ import ( "github.com/spf13/cobra" ) -var commitFormat string +var ( + commitFormat string + commitName string + commitDescription string +) // commitCmd is the parent command for commit operations. // Bare `vers commit` (no args, no subcommand) creates a commit of HEAD for backward compat. @@ -42,7 +46,14 @@ var commitCreateCmd = &cobra.Command{ Long: `Save the current state of a VM as a commit. If no VM ID or alias is provided, commits the current HEAD VM. -Use --format json for machine-readable output.`, +Use --name to give the commit a human-readable name. +Use --description to add additional context. +Use --format 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"`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { target := "" @@ -54,7 +65,9 @@ Use --format json for machine-readable output.`, defer cancel() res, err := handlers.HandleCommitCreate(apiCtx, application, handlers.CommitCreateReq{ - Target: target, + Target: target, + Name: commitName, + Description: commitDescription, }) if err != nil { return err @@ -70,6 +83,12 @@ Use --format json for machine-readable output.`, } 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) + } } return nil }, @@ -228,6 +247,8 @@ func init() { rootCmd.AddCommand(commitCmd) commitCreateCmd.Flags().StringVar(&commitFormat, "format", "", "Output format (json)") + commitCreateCmd.Flags().StringVarP(&commitName, "name", "n", "", "Human-readable name for the commit") + commitCreateCmd.Flags().StringVarP(&commitDescription, "description", "d", "", "Description for the commit") commitCmd.AddCommand(commitCreateCmd) commitListCmd.Flags().BoolVar(&commitListPublic, "public", false, "List public commits instead of your own") diff --git a/internal/handlers/commit_create.go b/internal/handlers/commit_create.go index b892baf..6fa3a46 100644 --- a/internal/handlers/commit_create.go +++ b/internal/handlers/commit_create.go @@ -7,16 +7,21 @@ import ( "github.com/hdresearch/vers-cli/internal/app" "github.com/hdresearch/vers-cli/internal/utils" vers "github.com/hdresearch/vers-sdk-go" + "github.com/hdresearch/vers-sdk-go/option" ) type CommitCreateReq struct { - Target string + Target string + Name string + Description string } type CommitCreateView struct { - CommitID string `json:"commit_id"` - VmID string `json:"vm_id"` - UsedHEAD bool `json:"used_head,omitempty"` + 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"` } func HandleCommitCreate(ctx context.Context, a *app.App, r CommitCreateReq) (CommitCreateView, error) { @@ -25,14 +30,25 @@ func HandleCommitCreate(ctx context.Context, a *app.App, r CommitCreateReq) (Com return CommitCreateView{}, err } - resp, err := a.Client.Vm.Commit(ctx, resolved.ID, vers.VmCommitParams{}) + // Build request options to send name/description in the request body + 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) } return CommitCreateView{ - CommitID: resp.CommitID, - VmID: resolved.ID, - UsedHEAD: resolved.UsedHEAD, + CommitID: resp.CommitID, + VmID: resolved.ID, + UsedHEAD: resolved.UsedHEAD, + Name: r.Name, + Description: r.Description, }, nil } diff --git a/internal/handlers/commit_create_test.go b/internal/handlers/commit_create_test.go new file mode 100644 index 0000000..bafadfa --- /dev/null +++ b/internal/handlers/commit_create_test.go @@ -0,0 +1,153 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hdresearch/vers-cli/internal/handlers" +) + +func TestHandleCommitCreate_WithName(t *testing.T) { + var commitBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/vm/vm-123/status": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"vm_id":"vm-123","owner_id":"owner-1","created_at":"2026-01-01T00:00:00Z","state":"running"}`)) + + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/vm/vm-123/commit": + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &commitBody) + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"commit_id":"commit-abc"}`)) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + a := testApp(server.URL) + res, err := handlers.HandleCommitCreate(context.Background(), a, handlers.CommitCreateReq{ + Target: "vm-123", + Name: "my-commit", + Description: "my description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.CommitID != "commit-abc" { + t.Errorf("expected commit ID commit-abc, got %s", res.CommitID) + } + if res.VmID != "vm-123" { + t.Errorf("expected VM ID vm-123, got %s", res.VmID) + } + if res.Name != "my-commit" { + t.Errorf("expected name my-commit, got %s", res.Name) + } + if res.Description != "my description" { + t.Errorf("expected description 'my description', got %s", res.Description) + } + + // Verify name and description were sent in the commit request body + if commitBody == nil { + t.Fatal("expected commit request to have a body") + } + if commitBody["name"] != "my-commit" { + t.Errorf("expected body name=my-commit, got %v", commitBody["name"]) + } + if commitBody["description"] != "my description" { + t.Errorf("expected body description='my description', got %v", commitBody["description"]) + } +} + +func TestHandleCommitCreate_WithoutName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/vm/vm-123/status": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"vm_id":"vm-123","owner_id":"owner-1","created_at":"2026-01-01T00:00:00Z","state":"running"}`)) + + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/vm/vm-123/commit": + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"commit_id":"commit-abc"}`)) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + a := testApp(server.URL) + res, err := handlers.HandleCommitCreate(context.Background(), a, handlers.CommitCreateReq{ + Target: "vm-123", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.CommitID != "commit-abc" { + t.Errorf("expected commit ID commit-abc, got %s", res.CommitID) + } + if res.Name != "" { + t.Errorf("expected empty name, got %s", res.Name) + } +} + +func TestHandleCommitCreate_NameOnly(t *testing.T) { + var commitBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/vm/vm-123/status": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"vm_id":"vm-123","owner_id":"owner-1","created_at":"2026-01-01T00:00:00Z","state":"running"}`)) + + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/vm/vm-123/commit": + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &commitBody) + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"commit_id":"commit-abc"}`)) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + a := testApp(server.URL) + res, err := handlers.HandleCommitCreate(context.Background(), a, handlers.CommitCreateReq{ + Target: "vm-123", + Name: "just-a-name", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Name != "just-a-name" { + t.Errorf("expected name just-a-name, got %s", res.Name) + } + if res.Description != "" { + t.Errorf("expected empty description, got %s", res.Description) + } + + // Verify name was sent but description was not + if commitBody["name"] != "just-a-name" { + t.Errorf("expected name in body, got %v", commitBody["name"]) + } + if _, hasDesc := commitBody["description"]; hasDesc { + t.Error("description should not be in body when not provided") + } +}