@@ -2,9 +2,14 @@ package handlers
22
33import (
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
2757func 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