Skip to content

Commit 8010630

Browse files
Merge pull request #203 from hdresearch/feat/commit-create-tag-publish
feat(commit): add --tag and --public flags to commit create
2 parents dbdfff8 + 58f7bcd commit 8010630

6 files changed

Lines changed: 454 additions & 23 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ vers commit
8888
# Commit a specific VM
8989
vers commit <vm-id>
9090

91+
# Commit + tag + publish in one shot
92+
vers commit create <vm-id> --tag my-app:v1.2 --tag my-app:latest --public
93+
# --tag <repo>:<tag> (repeatable) creates the tag, or updates an existing one
94+
# to point at the new commit
95+
# --public publishes the new commit (is_public=true)
96+
9197
# List your commits
9298
vers commit list
9399
vers commit list -q # just IDs

cmd/commit.go

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ var (
1414
commitFormat string
1515
commitName string
1616
commitDescription string
17+
commitTags []string
18+
commitPublic bool
1719
)
1820

1921
// commitCmd is the parent command for commit operations.
@@ -49,12 +51,15 @@ If no VM ID or alias is provided, commits the current HEAD VM.
4951
5052
Use --name to give the commit a human-readable name.
5153
Use --description to add additional context.
54+
Use --tag <repo>:<tag> (repeatable) to create or update repo tags pointing at the new commit.
55+
Use --public to publish the commit (set is_public=true) after it lands.
5256
Use --json for machine-readable output.
5357
5458
Examples:
5559
vers commit create --name "golden-image-v3"
5660
vers commit create --name "pre-deploy" --description "Before deploying auth changes"
57-
vers commit create vm-123 --name "checkpoint"`,
61+
vers commit create vm-123 --name "checkpoint"
62+
vers commit create vm-123 --tag my-app:v1.2 --tag my-app:latest --public`,
5863
Args: cobra.MaximumNArgs(1),
5964
RunE: func(cmd *cobra.Command, args []string) error {
6065
target := ""
@@ -69,6 +74,8 @@ Examples:
6974
Target: target,
7075
Name: commitName,
7176
Description: commitDescription,
77+
Tags: commitTags,
78+
Public: commitPublic,
7279
})
7380
if err != nil {
7481
return err
@@ -82,17 +89,7 @@ Examples:
8289
case pres.FormatJSON:
8390
pres.PrintJSON(res)
8491
default:
85-
if res.UsedHEAD {
86-
fmt.Printf("Using current HEAD VM: %s\n", res.VmID)
87-
}
88-
fmt.Printf("Committed VM '%s'\n", res.VmID)
89-
fmt.Printf("Commit ID: %s\n", res.CommitID)
90-
if res.Name != "" {
91-
fmt.Printf("Name: %s\n", res.Name)
92-
}
93-
if res.Description != "" {
94-
fmt.Printf("Description: %s\n", res.Description)
95-
}
92+
pres.RenderCommitCreate(application, res)
9693
}
9794
return nil
9895
},
@@ -284,6 +281,8 @@ func init() {
284281
_ = commitCreateCmd.Flags().MarkDeprecated("format", "use --json instead")
285282
commitCreateCmd.Flags().StringVarP(&commitName, "name", "n", "", "Human-readable name for the commit")
286283
commitCreateCmd.Flags().StringVarP(&commitDescription, "description", "d", "", "Description for the commit")
284+
commitCreateCmd.Flags().StringSliceVar(&commitTags, "tag", nil, "Repo tag to write pointing at the new commit, in <repo>:<tag> form (repeatable)")
285+
commitCreateCmd.Flags().BoolVar(&commitPublic, "public", false, "Publish the commit (set is_public=true) after it lands")
287286
commitCmd.AddCommand(commitCreateCmd)
288287

289288
commitListCmd.Flags().BoolVar(&commitListPublic, "public", false, "List public commits instead of your own")

internal/handlers/commit_create.go

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@ package handlers
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
610

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

19-
type CommitCreateView struct {
20-
CommitID string `json:"commit_id"`
21-
VmID string `json:"vm_id"`
22-
UsedHEAD bool `json:"used_head,omitempty"`
23-
Name string `json:"name,omitempty"`
24-
Description string `json:"description,omitempty"`
31+
// CommitCreateView is re-exported from the presenters package so callers
32+
// can keep importing it from handlers as before.
33+
type CommitCreateView = presenters.CommitCreateView
34+
35+
// CommitTagWritten is re-exported for the same reason.
36+
type CommitTagWritten = presenters.CommitTagWritten
37+
38+
// parsed form of a single --tag value
39+
type tagSpec struct {
40+
reference string // original "repo:tag" input
41+
repo string
42+
tag string
43+
}
44+
45+
func parseTagSpecs(raw []string) ([]tagSpec, error) {
46+
specs := make([]tagSpec, 0, len(raw))
47+
for _, s := range raw {
48+
parts := strings.SplitN(s, ":", 2)
49+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
50+
return nil, fmt.Errorf("--tag must be in <repo>:<tag> form (got: %q)", s)
51+
}
52+
specs = append(specs, tagSpec{reference: s, repo: parts[0], tag: parts[1]})
53+
}
54+
return specs, nil
2555
}
2656

2757
func HandleCommitCreate(ctx context.Context, a *app.App, r CommitCreateReq) (CommitCreateView, error) {
58+
// 1. Validate --tag shape up-front, before any side effects.
59+
specs, err := parseTagSpecs(r.Tags)
60+
if err != nil {
61+
return CommitCreateView{}, err
62+
}
63+
64+
// 2. Verify every referenced repo exists and capture its visibility.
65+
// Fail-fast before creating the commit if any repo is missing.
66+
repoPublic := make(map[string]bool, len(specs))
67+
for _, s := range specs {
68+
if _, seen := repoPublic[s.repo]; seen {
69+
continue
70+
}
71+
info, err := a.Client.Repositories.Get(ctx, s.repo)
72+
if err != nil {
73+
var apiErr *vers.Error
74+
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
75+
return CommitCreateView{}, fmt.Errorf("repo %q not found. Create it first with: vers repo create %s", s.repo, s.repo)
76+
}
77+
return CommitCreateView{}, fmt.Errorf("failed to look up repo %q: %w", s.repo, err)
78+
}
79+
repoPublic[s.repo] = info.IsPublic
80+
}
81+
82+
// 3. Resolve target VM.
2883
resolved, err := utils.ResolveTargetVM(ctx, a.Client, r.Target)
2984
if err != nil {
3085
return CommitCreateView{}, err
3186
}
3287

33-
// Build request options to send name/description in the request body
88+
// 4. Create the commit.
3489
var opts []option.RequestOption
3590
if r.Name != "" {
3691
opts = append(opts, option.WithJSONSet("name", r.Name))
3792
}
3893
if r.Description != "" {
3994
opts = append(opts, option.WithJSONSet("description", r.Description))
4095
}
41-
4296
resp, err := a.Client.Vm.Commit(ctx, resolved.ID, vers.VmCommitParams{}, opts...)
4397
if err != nil {
4498
return CommitCreateView{}, fmt.Errorf("failed to commit VM '%s': %w", resolved.ID, err)
4599
}
100+
commitID := resp.CommitID
46101

47-
return CommitCreateView{
48-
CommitID: resp.CommitID,
102+
view := presenters.CommitCreateView{
103+
CommitID: commitID,
49104
VmID: resolved.ID,
50105
UsedHEAD: resolved.UsedHEAD,
51106
Name: r.Name,
52107
Description: r.Description,
53-
}, nil
108+
}
109+
110+
// 5. Write tags. Any failure here returns an error that names the new
111+
// commit ID so the user can recover by hand.
112+
for _, s := range specs {
113+
existing, getErr := a.Client.Repositories.GetTag(ctx, s.repo, s.tag)
114+
var apiErr *vers.Error
115+
switch {
116+
case getErr == nil && existing != nil:
117+
// Tag exists -> update it to point at the new commit.
118+
updErr := HandleRepoTagUpdate(ctx, a, RepoTagUpdateReq{
119+
RepoName: s.repo,
120+
TagName: s.tag,
121+
CommitID: commitID,
122+
})
123+
if updErr != nil {
124+
return view, fmt.Errorf("commit %s created, but failed to update tag %s: %w", commitID, s.reference, updErr)
125+
}
126+
view.TagsWritten = append(view.TagsWritten, presenters.CommitTagWritten{
127+
Reference: s.reference,
128+
TagID: existing.TagID,
129+
})
130+
case errors.As(getErr, &apiErr) && apiErr.StatusCode == http.StatusNotFound:
131+
// Tag does not exist -> create it pointing at the new commit.
132+
created, createErr := HandleRepoTagCreate(ctx, a, RepoTagCreateReq{
133+
RepoName: s.repo,
134+
TagName: s.tag,
135+
CommitID: commitID,
136+
})
137+
if createErr != nil {
138+
return view, fmt.Errorf("commit %s created, but failed to create tag %s: %w", commitID, s.reference, createErr)
139+
}
140+
view.TagsWritten = append(view.TagsWritten, presenters.CommitTagWritten{
141+
Reference: s.reference,
142+
TagID: created.TagID,
143+
})
144+
default:
145+
return view, fmt.Errorf("commit %s created, but failed to look up tag %s: %w", commitID, s.reference, getErr)
146+
}
147+
}
148+
149+
// 6. Publish if requested.
150+
if r.Public {
151+
info, pubErr := HandleCommitUpdate(ctx, a, CommitUpdateReq{
152+
CommitID: commitID,
153+
IsPublic: true,
154+
})
155+
if pubErr != nil {
156+
return view, fmt.Errorf("commit %s created (tags written: %d), but failed to publish: %w", commitID, len(view.TagsWritten), pubErr)
157+
}
158+
view.IsPublic = info.IsPublic
159+
}
160+
161+
// 7. Visibility-mismatch warning: any tag target repo is public but
162+
// the commit was not explicitly published. Stderr only — do not
163+
// auto-publish (the conservative recommendation from #201).
164+
if !r.Public {
165+
var publicRepos []string
166+
for repo, pub := range repoPublic {
167+
if pub {
168+
publicRepos = append(publicRepos, repo)
169+
}
170+
}
171+
if len(publicRepos) > 0 {
172+
out := a.IO.Err
173+
if out == nil {
174+
out = io.Discard
175+
}
176+
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",
177+
commitID, strings.Join(publicRepos, ", "), commitID)
178+
}
179+
}
180+
181+
return view, nil
54182
}

0 commit comments

Comments
 (0)