@@ -104,9 +104,19 @@ var ghRepoNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
104104
105105// allowed visibility values.
106106const (
107- visibilityPublic = "public"
108- visibilityPrivate = "private"
109- visibilityInternal = "internal"
107+ visibilityPublic = "public"
108+ visibilityPrivate = "private"
109+ visibilityInternal = "internal"
110+ defaultInitialCommitMessage = "Initial commit"
111+ )
112+
113+ type bootstrapSetupChoice string
114+
115+ const (
116+ bootstrapSetupLocal bootstrapSetupChoice = "local"
117+ bootstrapSetupGitHub bootstrapSetupChoice = "github"
118+ bootstrapSetupCustom bootstrapSetupChoice = "custom"
119+ bootstrapSetupDecline bootstrapSetupChoice = "decline"
110120)
111121
112122// bootstrapState carries pre-setup decisions into the post-setup finalize
@@ -146,13 +156,29 @@ func runGitHubBootstrapInitWith(ctx context.Context, w, errW io.Writer, opts Git
146156 return nil , fmt .Errorf ("get working directory: %w" , err )
147157 }
148158
149- // Step 1: confirm we should git init here.
150- proceed , err := confirmInitRepo (w , cwd , opts )
151- if err != nil {
152- return nil , err
153- }
154- if ! proceed {
155- return nil , errBootstrapDeclined
159+ // Step 1: decide whether to init here — and, on the bare interactive
160+ // path, how: one select carries both the init consent and the setup
161+ // preset, so the common flow costs a single answer. Explicit flags,
162+ // --yes, and non-interactive runs keep the granular confirm + resolver
163+ // contracts unchanged.
164+ setupChoice := bootstrapSetupCustom
165+ if shouldPromptBootstrapSetupChoice (opts ) {
166+ githubReady := ghAvailable (ctx , runner ) && ghAuthenticated (ctx , runner )
167+ setupChoice , err = promptBootstrapSetupChoice (w , cwd , githubReady )
168+ if err != nil {
169+ return nil , err
170+ }
171+ if setupChoice == bootstrapSetupDecline {
172+ return nil , errBootstrapDeclined
173+ }
174+ } else {
175+ proceed , confirmErr := confirmInitRepo (w , cwd , opts )
176+ if confirmErr != nil {
177+ return nil , confirmErr
178+ }
179+ if ! proceed {
180+ return nil , errBootstrapDeclined
181+ }
156182 }
157183
158184 // Step 2: git init.
@@ -165,13 +191,12 @@ func runGitHubBootstrapInitWith(ctx context.Context, w, errW io.Writer, opts Git
165191 paths .ClearWorktreeRootCache ()
166192 fmt .Fprintln (w , " ✓ Initialized empty git repository" )
167193
168- // Step 3: decide whether to create a GitHub repo. Creating a remote is an
169- // explicit opt-in: it happens only on an explicit signal (repo flags,
170- // --push, or --yes) or an interactive "yes". A non-interactive run with no
171- // such signal stays local-only — we never create a repo on the user's
172- // behalf. --no-github always wins.
173- useGitHub := false
174- if ! opts .NoGitHub {
194+ // Creating a remote remains an explicit opt-in. Choosing the GitHub preset
195+ // is that consent: its label states that the private repository will be
196+ // created and the initial commit pushed. The local preset never creates or
197+ // pushes a remote.
198+ useGitHub := setupChoice == bootstrapSetupGitHub
199+ if setupChoice == bootstrapSetupCustom && ! opts .NoGitHub {
175200 explicit := ghCreateRequested (opts )
176201 // Only probe gh (and warn about a missing/unauthenticated CLI) when the
177202 // user actually wants a GitHub repo — explicitly, or via the confirm
@@ -187,7 +212,6 @@ func runGitHubBootstrapInitWith(ctx context.Context, w, errW io.Writer, opts Git
187212 case explicit :
188213 useGitHub = true
189214 default :
190- // Interactive with no explicit signal: prompt, defaulting to No.
191215 confirmed , err := confirmCreateGitHubRepo (cwd )
192216 if err != nil {
193217 return nil , err
@@ -201,7 +225,13 @@ func runGitHubBootstrapInitWith(ctx context.Context, w, errW io.Writer, opts Git
201225 // contiguous.
202226 var fullName , visibility string
203227 if useGitHub {
204- owner , name , vis , err := selectGitHubRepo (ctx , w , errW , runner , cwd , opts )
228+ repoOpts := opts
229+ if setupChoice == bootstrapSetupGitHub {
230+ // The GitHub preset resolves the current user, folder-derived name,
231+ // and private visibility without reopening the granular prompts.
232+ repoOpts .Yes = true
233+ }
234+ owner , name , vis , err := selectGitHubRepo (ctx , w , errW , runner , cwd , repoOpts )
205235 if err != nil {
206236 return nil , err
207237 }
@@ -216,9 +246,12 @@ func runGitHubBootstrapInitWith(ctx context.Context, w, errW io.Writer, opts Git
216246 // because gh may read local config; but we can skip the identity
217247 // check when the user is fully opting out of both commit and
218248 // remote to keep the flow minimal.
219- message , commit , err := resolveCommitMessage (opts )
220- if err != nil {
221- return nil , err
249+ message , commit := defaultInitialCommitMessage , true
250+ if setupChoice == bootstrapSetupCustom {
251+ message , commit , err = resolveCommitMessage (opts )
252+ if err != nil {
253+ return nil , err
254+ }
222255 }
223256 if commit {
224257 if err := ensureGitIdentity (ctx , w , errW , runner , cwd ); err != nil {
@@ -234,6 +267,9 @@ func runGitHubBootstrapInitWith(ctx context.Context, w, errW io.Writer, opts Git
234267 push := false
235268 if useGitHub && commit {
236269 switch {
270+ case setupChoice == bootstrapSetupGitHub :
271+ // The preset label explicitly includes pushing the initial commit.
272+ push = true
237273 case opts .Yes || opts .Push :
238274 push = true
239275 case interactive .CanPromptInteractively ():
@@ -429,6 +465,65 @@ func confirmInitRepo(_ io.Writer, cwd string, opts GitHubBootstrapOptions) (bool
429465 return confirmed , nil
430466}
431467
468+ // shouldPromptBootstrapSetupChoice reports whether this is the bare
469+ // interactive bootstrap path. Any option that expresses a granular choice —
470+ // including --init-repo / --no-init-repo, which answer the init consent the
471+ // merged select carries — keeps the established flag behavior instead of
472+ // being overwritten by a preset.
473+ func shouldPromptBootstrapSetupChoice (opts GitHubBootstrapOptions ) bool {
474+ return interactive .CanPromptInteractively () &&
475+ ! opts .Yes &&
476+ ! opts .InitRepo &&
477+ ! opts .NoInitRepo &&
478+ ! opts .NoGitHub &&
479+ ! ghFlagsProvided (opts ) &&
480+ ! opts .Push &&
481+ opts .InitialCommitMessage == "" &&
482+ ! opts .SkipInitialCommit
483+ }
484+
485+ // promptBootstrapSetupChoice merges the init consent and the common
486+ // bootstrap decisions into one select, so the bare interactive path costs a
487+ // single answer. It runs _before_ `git init`: declining — including Ctrl-C —
488+ // leaves the folder untouched. The selected commit is still deferred until
489+ // Entire has written its settings and agent configuration.
490+ //
491+ // The wrong-directory guard from the granular confirm (issue #1717) carries
492+ // over: the absolute path stays in the title (the accessible renderer drops
493+ // descriptions), and the menu makes the choice visible before Enter lands on
494+ // the recommended preset.
495+ func promptBootstrapSetupChoice (w io.Writer , cwd string , githubReady bool ) (bootstrapSetupChoice , error ) {
496+ options := []huh.Option [bootstrapSetupChoice ]{
497+ huh .NewOption ("Yes, with one initial commit (recommended)" , bootstrapSetupLocal ),
498+ }
499+ if githubReady {
500+ options = append (options ,
501+ huh .NewOption ("Yes, plus a private GitHub repository (pushed)" , bootstrapSetupGitHub ),
502+ )
503+ }
504+ options = append (options ,
505+ huh .NewOption ("Yes, customize..." , bootstrapSetupCustom ),
506+ huh .NewOption ("No" , bootstrapSetupDecline ),
507+ )
508+
509+ choice := bootstrapSetupLocal
510+ form := NewAccessibleForm (
511+ huh .NewGroup (
512+ huh .NewSelect [bootstrapSetupChoice ]().
513+ Title (fmt .Sprintf ("No git repository in %q. Set one up?" , cwd )).
514+ Options (options ... ).
515+ Value (& choice ),
516+ ),
517+ ).WithOutput (w )
518+ if err := form .Run (); err != nil {
519+ if errors .Is (err , huh .ErrUserAborted ) {
520+ return bootstrapSetupDecline , nil
521+ }
522+ return "" , fmt .Errorf ("git setup prompt: %w" , err )
523+ }
524+ return choice , nil
525+ }
526+
432527// selectGitHubRepo gathers owner, repo name, and visibility, respecting
433528// supplied flags and falling back to interactive prompts.
434529func selectGitHubRepo (ctx context.Context , w , errW io.Writer , runner bootstrapRunner , cwd string , opts GitHubBootstrapOptions ) (owner , name , visibility string , err error ) {
@@ -633,16 +728,14 @@ func resolveVisibility(owner, currentUser string, opts GitHubBootstrapOptions) (
633728// the initial commit entirely; callers must skip `doInitialCommit` and
634729// any subsequent push.
635730func resolveCommitMessage (opts GitHubBootstrapOptions ) (string , bool , error ) {
636- const defaultMsg = "Initial commit"
637-
638731 if opts .SkipInitialCommit {
639732 return "" , false , nil
640733 }
641734 if opts .InitialCommitMessage != "" {
642735 return opts .InitialCommitMessage , true , nil
643736 }
644737 if opts .Yes || ! interactive .CanPromptInteractively () {
645- return defaultMsg , true , nil
738+ return defaultInitialCommitMessage , true , nil
646739 }
647740
648741 const (
@@ -674,7 +767,7 @@ func resolveCommitMessage(opts GitHubBootstrapOptions) (string, bool, error) {
674767 case choiceSkip :
675768 return "" , false , nil
676769 case choiceCustomize :
677- input := defaultMsg
770+ input := defaultInitialCommitMessage
678771 custom := NewAccessibleForm (
679772 huh .NewGroup (
680773 huh .NewInput ().
@@ -689,11 +782,11 @@ func resolveCommitMessage(opts GitHubBootstrapOptions) (string, bool, error) {
689782 return "" , false , fmt .Errorf ("commit message prompt: %w" , err )
690783 }
691784 if strings .TrimSpace (input ) == "" {
692- return defaultMsg , true , nil
785+ return defaultInitialCommitMessage , true , nil
693786 }
694787 return input , true , nil
695788 default :
696- return defaultMsg , true , nil
789+ return defaultInitialCommitMessage , true , nil
697790 }
698791}
699792
0 commit comments