From fad32435e9de1a3834e42a605c7970b930b0e912 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:06:42 +0100 Subject: [PATCH 01/10] feat: add full execution --- .goreleaser.yaml | 1 + Makefile | 5 +- cmd/root.go | 136 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 29da2e3..96fb703 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -87,6 +87,7 @@ scoops: name: scoop-bucket branch: main token: "{{ .Env.SCOOP_BUCKET_TOKEN }}" + directory: bucket homepage: https://github.com/shinokada/gitstart description: A CLI tool for git project initialization. license: MIT diff --git a/Makefile b/Makefile index 2b0e06e..b371fa3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build test clean clean-cache clean-all run lint lint-fix coverage coverage-ci install +.PHONY: all build test clean clean-cache clean-all run lint lint-fix coverage coverage-ci install ci # Default target all: build @@ -44,6 +44,9 @@ clean-cache: # Clean everything (build artifacts + caches) clean-all: clean clean-cache +# Clean, lint, test, and build +ci: clean-all lint test build + # Install the CLI tool install: go install . diff --git a/cmd/root.go b/cmd/root.go index b12253a..927a0ac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,10 +3,14 @@ package cmd import ( "fmt" "os" + "path/filepath" "runtime/debug" "strconv" + "github.com/shinokada/gitstart/internal/config" + "github.com/shinokada/gitstart/internal/files" "github.com/shinokada/gitstart/internal/prompts" + "github.com/shinokada/gitstart/internal/repo" "github.com/spf13/cobra" ) @@ -76,8 +80,10 @@ More examples: prompts.DryRunPrompt("No actions will be performed in dry-run mode.") return } - // TODO: implement full execution path - fmt.Fprintln(os.Stderr, "error: full execution not yet implemented; use --dry-run to preview actions") + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } }, } @@ -111,6 +117,132 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Minimal output") } +func run() error { + // Resolve directory + dir := directory + if dir == "." { + var err error + dir, err = os.Getwd() + if err != nil { + return fmt.Errorf("could not get current directory: %w", err) + } + } + repoName := filepath.Base(dir) + + // Create project directory if needed + if err := files.CreateProjectDir(dir); err != nil { + return fmt.Errorf("could not create directory %q: %w", dir, err) + } + + if !quiet { + fmt.Printf("Setting up project %q in %s\n", repoName, dir) + } + + // Create .gitignore + gitignorePath := filepath.Join(dir, ".gitignore") + if _, err := os.Stat(gitignorePath); os.IsNotExist(err) { + if !quiet { + fmt.Println("Creating .gitignore...") + } + if err := files.FetchGitignore(language, gitignorePath); err != nil { + return fmt.Errorf("could not create .gitignore: %w", err) + } + } else if !quiet { + fmt.Println(".gitignore already exists, skipping.") + } + + // Select and create LICENSE + licensePath := filepath.Join(dir, "LICENSE") + if _, err := os.Stat(licensePath); os.IsNotExist(err) { + licenseOptions := []string{ + "mit: Simple and permissive", + "apache-2.0: Community-friendly", + "gpl-3.0: Share improvements", + "None", + } + choice := prompts.PromptSelect("Select a license:", licenseOptions) + switch choice { + case licenseOptions[0]: + if err := files.FetchLicenseText("mit", licensePath); err != nil { + return fmt.Errorf("could not create LICENSE: %w", err) + } + case licenseOptions[1]: + if err := files.FetchLicenseText("apache-2.0", licensePath); err != nil { + return fmt.Errorf("could not create LICENSE: %w", err) + } + case licenseOptions[2]: + if err := files.FetchLicenseText("gpl-3.0", licensePath); err != nil { + return fmt.Errorf("could not create LICENSE: %w", err) + } + default: + if !quiet { + fmt.Println("Skipping LICENSE.") + } + } + } else if !quiet { + fmt.Println("LICENSE already exists, skipping.") + } + + // Create README.md + readmePath := filepath.Join(dir, "README.md") + if _, err := os.Stat(readmePath); os.IsNotExist(err) { + if !quiet { + fmt.Println("Creating README.md...") + } + if err := files.CreateReadme(repoName, description, readmePath); err != nil { + return fmt.Errorf("could not create README.md: %w", err) + } + } else if !quiet { + fmt.Println("README.md already exists, skipping.") + } + + // Initialize git repo if needed + gitDir := filepath.Join(dir, ".git") + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + if !quiet { + fmt.Println("Initializing git repository...") + } + if err := repo.InitGitRepo(dir); err != nil { + return fmt.Errorf("could not initialize git repository: %w", err) + } + } else if !quiet { + fmt.Println("Git repository already exists, skipping init.") + } + + // Load or prompt for GitHub username + username, err := config.LoadUsername() + if err != nil || username == "" { + username = prompts.PromptUser("Enter your GitHub username: ") + if username == "" { + return fmt.Errorf("GitHub username is required") + } + if err := config.SaveUsername(username); err != nil { + return fmt.Errorf("could not save username: %w", err) + } + } + + // Determine visibility + visibility := "" + if private { + visibility = "private" + } else if public { + visibility = "public" + } + + // Create GitHub repo and push + if !quiet { + fmt.Printf("Creating GitHub repository %s/%s...\n", username, repoName) + } + if err := repo.CreateGitHubRepo(dir, repoName, visibility, description); err != nil { + return fmt.Errorf("could not create GitHub repository: %w", err) + } + + if !quiet { + fmt.Printf("✓ Done! Repository available at https://github.com/%s/%s\n", username, repoName) + } + return nil +} + func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) From 3a58b57f83520359b63996aeaab6aaec3ca20fbb Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:19:31 +0100 Subject: [PATCH 02/10] fix: stat error handling, wire branch/message flags to commit and push --- cmd/root.go | 50 ++++++++++++++++++++++++++++++++----------- internal/repo/repo.go | 5 +++-- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 927a0ac..985fe76 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -140,20 +140,28 @@ func run() error { // Create .gitignore gitignorePath := filepath.Join(dir, ".gitignore") - if _, err := os.Stat(gitignorePath); os.IsNotExist(err) { + if _, err := os.Stat(gitignorePath); err == nil { + if !quiet { + fmt.Println(".gitignore already exists, skipping.") + } + } else if os.IsNotExist(err) { if !quiet { fmt.Println("Creating .gitignore...") } if err := files.FetchGitignore(language, gitignorePath); err != nil { return fmt.Errorf("could not create .gitignore: %w", err) } - } else if !quiet { - fmt.Println(".gitignore already exists, skipping.") + } else { + return fmt.Errorf("could not access %s: %w", gitignorePath, err) } // Select and create LICENSE licensePath := filepath.Join(dir, "LICENSE") - if _, err := os.Stat(licensePath); os.IsNotExist(err) { + if _, err := os.Stat(licensePath); err == nil { + if !quiet { + fmt.Println("LICENSE already exists, skipping.") + } + } else if os.IsNotExist(err) { licenseOptions := []string{ "mit: Simple and permissive", "apache-2.0: Community-friendly", @@ -179,34 +187,42 @@ func run() error { fmt.Println("Skipping LICENSE.") } } - } else if !quiet { - fmt.Println("LICENSE already exists, skipping.") + } else { + return fmt.Errorf("could not access %s: %w", licensePath, err) } // Create README.md readmePath := filepath.Join(dir, "README.md") - if _, err := os.Stat(readmePath); os.IsNotExist(err) { + if _, err := os.Stat(readmePath); err == nil { + if !quiet { + fmt.Println("README.md already exists, skipping.") + } + } else if os.IsNotExist(err) { if !quiet { fmt.Println("Creating README.md...") } if err := files.CreateReadme(repoName, description, readmePath); err != nil { return fmt.Errorf("could not create README.md: %w", err) } - } else if !quiet { - fmt.Println("README.md already exists, skipping.") + } else { + return fmt.Errorf("could not access %s: %w", readmePath, err) } // Initialize git repo if needed gitDir := filepath.Join(dir, ".git") - if _, err := os.Stat(gitDir); os.IsNotExist(err) { + if _, err := os.Stat(gitDir); err == nil { + if !quiet { + fmt.Println("Git repository already exists, skipping init.") + } + } else if os.IsNotExist(err) { if !quiet { fmt.Println("Initializing git repository...") } if err := repo.InitGitRepo(dir); err != nil { return fmt.Errorf("could not initialize git repository: %w", err) } - } else if !quiet { - fmt.Println("Git repository already exists, skipping init.") + } else { + return fmt.Errorf("could not access %s: %w", gitDir, err) } // Load or prompt for GitHub username @@ -229,7 +245,7 @@ func run() error { visibility = "public" } - // Create GitHub repo and push + // Create GitHub repo (sets remote origin, no push) if !quiet { fmt.Printf("Creating GitHub repository %s/%s...\n", username, repoName) } @@ -237,6 +253,14 @@ func run() error { return fmt.Errorf("could not create GitHub repository: %w", err) } + // Commit and push with user-supplied branch and message + if !quiet { + fmt.Printf("Committing and pushing to branch %q...\n", branch) + } + if err := repo.CommitAndPush(dir, branch, message); err != nil { + return fmt.Errorf("could not commit and push: %w", err) + } + if !quiet { fmt.Printf("✓ Done! Repository available at https://github.com/%s/%s\n", username, repoName) } diff --git a/internal/repo/repo.go b/internal/repo/repo.go index b8eb989..245dd1d 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -56,9 +56,10 @@ func CommitAndPush(dir, branch, message string) error { return nil } -// CreateGitHubRepo creates a GitHub repository using the gh CLI. +// CreateGitHubRepo creates a GitHub repository using the gh CLI and sets the remote origin, +// but does not push. Call CommitAndPush afterwards to stage, commit, and push. func CreateGitHubRepo(dir, repoName, visibility, description string) error { - args := []string{"gh", "repo", "create", repoName, "--source=.", "--remote=origin", "--push"} + args := []string{"gh", "repo", "create", repoName, "--source=.", "--remote=origin"} switch visibility { case "": case "public", "private", "internal": From 3453ae82fc1ecaf8b97839e211681946c6b4330c Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:28:19 +0100 Subject: [PATCH 03/10] fix: default visibility to public to prevent gh interactive prompt --- cmd/root.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 985fe76..0b89700 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -238,11 +238,9 @@ func run() error { } // Determine visibility - visibility := "" + visibility := "public" if private { visibility = "private" - } else if public { - visibility = "public" } // Create GitHub repo (sets remote origin, no push) From a9843275bfcf493ac7087c0d376d1075195d37a3 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:00:34 +0100 Subject: [PATCH 04/10] fix: resolve absolute path, skip license prompt in quiet mode, use gh auth user for URL --- cmd/root.go | 90 +++++++++++++++++++++++++++++------------------------ 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 0b89700..fadff91 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,13 +1,15 @@ package cmd import ( + "bytes" "fmt" "os" + "os/exec" "path/filepath" "runtime/debug" "strconv" + "strings" - "github.com/shinokada/gitstart/internal/config" "github.com/shinokada/gitstart/internal/files" "github.com/shinokada/gitstart/internal/prompts" "github.com/shinokada/gitstart/internal/repo" @@ -118,15 +120,16 @@ func init() { } func run() error { - // Resolve directory + // Resolve directory to an absolute, clean path dir := directory - if dir == "." { - var err error - dir, err = os.Getwd() + if !filepath.IsAbs(dir) { + wd, err := os.Getwd() if err != nil { return fmt.Errorf("could not get current directory: %w", err) } + dir = filepath.Join(wd, dir) } + dir = filepath.Clean(dir) repoName := filepath.Base(dir) // Create project directory if needed @@ -162,28 +165,30 @@ func run() error { fmt.Println("LICENSE already exists, skipping.") } } else if os.IsNotExist(err) { - licenseOptions := []string{ - "mit: Simple and permissive", - "apache-2.0: Community-friendly", - "gpl-3.0: Share improvements", - "None", - } - choice := prompts.PromptSelect("Select a license:", licenseOptions) - switch choice { - case licenseOptions[0]: - if err := files.FetchLicenseText("mit", licensePath); err != nil { - return fmt.Errorf("could not create LICENSE: %w", err) - } - case licenseOptions[1]: - if err := files.FetchLicenseText("apache-2.0", licensePath); err != nil { - return fmt.Errorf("could not create LICENSE: %w", err) - } - case licenseOptions[2]: - if err := files.FetchLicenseText("gpl-3.0", licensePath); err != nil { - return fmt.Errorf("could not create LICENSE: %w", err) + if quiet { + // Skip interactive prompt in quiet/non-interactive mode + } else { + licenseOptions := []string{ + "mit: Simple and permissive", + "apache-2.0: Community-friendly", + "gpl-3.0: Share improvements", + "None", } - default: - if !quiet { + choice := prompts.PromptSelect("Select a license:", licenseOptions) + switch choice { + case licenseOptions[0]: + if err := files.FetchLicenseText("mit", licensePath); err != nil { + return fmt.Errorf("could not create LICENSE: %w", err) + } + case licenseOptions[1]: + if err := files.FetchLicenseText("apache-2.0", licensePath); err != nil { + return fmt.Errorf("could not create LICENSE: %w", err) + } + case licenseOptions[2]: + if err := files.FetchLicenseText("gpl-3.0", licensePath); err != nil { + return fmt.Errorf("could not create LICENSE: %w", err) + } + default: fmt.Println("Skipping LICENSE.") } } @@ -225,18 +230,6 @@ func run() error { return fmt.Errorf("could not access %s: %w", gitDir, err) } - // Load or prompt for GitHub username - username, err := config.LoadUsername() - if err != nil || username == "" { - username = prompts.PromptUser("Enter your GitHub username: ") - if username == "" { - return fmt.Errorf("GitHub username is required") - } - if err := config.SaveUsername(username); err != nil { - return fmt.Errorf("could not save username: %w", err) - } - } - // Determine visibility visibility := "public" if private { @@ -245,7 +238,7 @@ func run() error { // Create GitHub repo (sets remote origin, no push) if !quiet { - fmt.Printf("Creating GitHub repository %s/%s...\n", username, repoName) + fmt.Printf("Creating GitHub repository %s...\n", repoName) } if err := repo.CreateGitHubRepo(dir, repoName, visibility, description); err != nil { return fmt.Errorf("could not create GitHub repository: %w", err) @@ -260,11 +253,28 @@ func run() error { } if !quiet { - fmt.Printf("✓ Done! Repository available at https://github.com/%s/%s\n", username, repoName) + // Fetch the authenticated gh username for the correct URL + ghUser := ghAuthenticatedUser() + if ghUser != "" { + fmt.Printf("✓ Done! Repository available at https://github.com/%s/%s\n", ghUser, repoName) + } else { + fmt.Printf("✓ Done! Repository: %s\n", repoName) + } } return nil } +// ghAuthenticatedUser returns the GitHub username of the currently authenticated gh CLI user. +func ghAuthenticatedUser() string { + var out bytes.Buffer + cmd := exec.Command("gh", "api", "user", "--jq", ".login") + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return "" + } + return strings.TrimSpace(out.String()) +} + func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) From 29abe99aff54950c904eaed99c8b32b812fc1b09 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:21:07 +0100 Subject: [PATCH 05/10] fix: remove hardcoded github.com URL, add timeout to gh auth user call --- cmd/root.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index fadff91..3821453 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,7 +1,7 @@ package cmd import ( - "bytes" + "context" "fmt" "os" "os/exec" @@ -9,6 +9,7 @@ import ( "runtime/debug" "strconv" "strings" + "time" "github.com/shinokada/gitstart/internal/files" "github.com/shinokada/gitstart/internal/prompts" @@ -256,7 +257,7 @@ func run() error { // Fetch the authenticated gh username for the correct URL ghUser := ghAuthenticatedUser() if ghUser != "" { - fmt.Printf("✓ Done! Repository available at https://github.com/%s/%s\n", ghUser, repoName) + fmt.Printf("✓ Done! Repository created: %s/%s\n", ghUser, repoName) } else { fmt.Printf("✓ Done! Repository: %s\n", repoName) } @@ -265,14 +266,16 @@ func run() error { } // ghAuthenticatedUser returns the GitHub username of the currently authenticated gh CLI user. +// A 5-second timeout guards against the CLI hanging on network or auth issues. func ghAuthenticatedUser() string { - var out bytes.Buffer - cmd := exec.Command("gh", "api", "user", "--jq", ".login") - cmd.Stdout = &out - if err := cmd.Run(); err != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "gh", "api", "user", "--jq", ".login") + out, err := cmd.Output() + if err != nil { return "" } - return strings.TrimSpace(out.String()) + return strings.TrimSpace(string(out)) } func Execute() { From db2aa47d85d491303bdf3d1c24c3cb38a5b3c8b6 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:39:41 +0100 Subject: [PATCH 06/10] fix: validate branch flag early, skip .gitignore when no language specified --- cmd/root.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 3821453..77104ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -36,6 +36,9 @@ var rootCmd = &cobra.Command{ if private && public { return fmt.Errorf("flags --private and --public are mutually exclusive") } + if strings.TrimSpace(branch) == "" { + return fmt.Errorf("flag --branch cannot be empty") + } return nil }, Long: `gitstart automates project setup, git init, and GitHub repo creation. @@ -142,9 +145,13 @@ func run() error { fmt.Printf("Setting up project %q in %s\n", repoName, dir) } - // Create .gitignore + // Create .gitignore only when --language is specified gitignorePath := filepath.Join(dir, ".gitignore") - if _, err := os.Stat(gitignorePath); err == nil { + if strings.TrimSpace(language) == "" { + if !quiet { + fmt.Println("No language specified, skipping .gitignore creation.") + } + } else if _, err := os.Stat(gitignorePath); err == nil { if !quiet { fmt.Println(".gitignore already exists, skipping.") } From a733ed0ce8df1505d1ec1ee3dd6a0feae50a0208 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:04:09 +0100 Subject: [PATCH 07/10] fix: normalize branch and language flags before use --- cmd/root.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 77104ff..463a97f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -36,7 +36,8 @@ var rootCmd = &cobra.Command{ if private && public { return fmt.Errorf("flags --private and --public are mutually exclusive") } - if strings.TrimSpace(branch) == "" { + branch = strings.TrimSpace(branch) + if branch == "" { return fmt.Errorf("flag --branch cannot be empty") } return nil @@ -147,7 +148,8 @@ func run() error { // Create .gitignore only when --language is specified gitignorePath := filepath.Join(dir, ".gitignore") - if strings.TrimSpace(language) == "" { + lang := strings.TrimSpace(language) + if lang == "" { if !quiet { fmt.Println("No language specified, skipping .gitignore creation.") } @@ -159,7 +161,7 @@ func run() error { if !quiet { fmt.Println("Creating .gitignore...") } - if err := files.FetchGitignore(language, gitignorePath); err != nil { + if err := files.FetchGitignore(lang, gitignorePath); err != nil { return fmt.Errorf("could not create .gitignore: %w", err) } } else { From 87fb3d58889d1044abd59ece8fb37a2013b1f867 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:17:22 +0100 Subject: [PATCH 08/10] refactor: split run() into helpers, fix dry-run output, normalize language casing for gitignore --- cmd/root.go | 177 ++++++++++++++++++++---------------- internal/files/gitignore.go | 79 ++++++++++++---- 2 files changed, 158 insertions(+), 98 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 463a97f..a2fe12f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -76,14 +76,21 @@ More examples: prompts.DryRunPrompt(" Dry-run: true") prompts.DryRunPrompt("[ACTIONS]") prompts.DryRunPrompt("Would create project directory if needed") - prompts.DryRunPrompt("Would create .gitignore for language if specified") - prompts.DryRunPrompt("Would prompt for and create LICENSE file") + if strings.TrimSpace(language) != "" { + prompts.DryRunPrompt("Would create .gitignore for language: " + language) + } else { + prompts.DryRunPrompt("Would skip .gitignore (no language specified)") + } + if quiet { + prompts.DryRunPrompt("Would skip LICENSE creation (quiet mode)") + } else { + prompts.DryRunPrompt("Would prompt for and create LICENSE file") + } prompts.DryRunPrompt("Would create README.md with project name and description") prompts.DryRunPrompt("Would initialize git repository if not present") prompts.DryRunPrompt("Would add all files and commit with message") prompts.DryRunPrompt("Would create GitHub repository (public/private as specified)") prompts.DryRunPrompt("Would add remote origin and push to branch") - prompts.DryRunPrompt("Would handle existing files and directories as described in documentation") prompts.DryRunPrompt("No actions will be performed in dry-run mode.") return } @@ -137,133 +144,147 @@ func run() error { dir = filepath.Clean(dir) repoName := filepath.Base(dir) - // Create project directory if needed if err := files.CreateProjectDir(dir); err != nil { return fmt.Errorf("could not create directory %q: %w", dir, err) } - if !quiet { fmt.Printf("Setting up project %q in %s\n", repoName, dir) } - // Create .gitignore only when --language is specified - gitignorePath := filepath.Join(dir, ".gitignore") + if err := ensureGitignore(dir); err != nil { + return err + } + if err := ensureLicense(dir); err != nil { + return err + } + if err := ensureReadme(dir, repoName); err != nil { + return err + } + if err := ensureGitRepo(dir); err != nil { + return err + } + if err := createRemoteAndPush(dir, repoName); err != nil { + return err + } + return nil +} + +func ensureGitignore(dir string) error { lang := strings.TrimSpace(language) if lang == "" { if !quiet { fmt.Println("No language specified, skipping .gitignore creation.") } - } else if _, err := os.Stat(gitignorePath); err == nil { + return nil + } + p := filepath.Join(dir, ".gitignore") + if _, err := os.Stat(p); err == nil { if !quiet { fmt.Println(".gitignore already exists, skipping.") } - } else if os.IsNotExist(err) { - if !quiet { - fmt.Println("Creating .gitignore...") - } - if err := files.FetchGitignore(lang, gitignorePath); err != nil { - return fmt.Errorf("could not create .gitignore: %w", err) - } - } else { - return fmt.Errorf("could not access %s: %w", gitignorePath, err) + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("could not access %s: %w", p, err) } + if !quiet { + fmt.Println("Creating .gitignore...") + } + if err := files.FetchGitignore(lang, p); err != nil { + return err + } + return nil +} - // Select and create LICENSE - licensePath := filepath.Join(dir, "LICENSE") - if _, err := os.Stat(licensePath); err == nil { +func ensureLicense(dir string) error { + p := filepath.Join(dir, "LICENSE") + if _, err := os.Stat(p); err == nil { if !quiet { fmt.Println("LICENSE already exists, skipping.") } - } else if os.IsNotExist(err) { - if quiet { - // Skip interactive prompt in quiet/non-interactive mode - } else { - licenseOptions := []string{ - "mit: Simple and permissive", - "apache-2.0: Community-friendly", - "gpl-3.0: Share improvements", - "None", - } - choice := prompts.PromptSelect("Select a license:", licenseOptions) - switch choice { - case licenseOptions[0]: - if err := files.FetchLicenseText("mit", licensePath); err != nil { - return fmt.Errorf("could not create LICENSE: %w", err) - } - case licenseOptions[1]: - if err := files.FetchLicenseText("apache-2.0", licensePath); err != nil { - return fmt.Errorf("could not create LICENSE: %w", err) - } - case licenseOptions[2]: - if err := files.FetchLicenseText("gpl-3.0", licensePath); err != nil { - return fmt.Errorf("could not create LICENSE: %w", err) - } - default: - fmt.Println("Skipping LICENSE.") - } - } - } else { - return fmt.Errorf("could not access %s: %w", licensePath, err) + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("could not access %s: %w", p, err) } + if quiet { + // Skip interactive prompt in quiet/non-interactive mode + return nil + } + licenseOptions := []string{ + "mit: Simple and permissive", + "apache-2.0: Community-friendly", + "gpl-3.0: Share improvements", + "None", + } + choice := prompts.PromptSelect("Select a license:", licenseOptions) + switch choice { + case licenseOptions[0]: + return files.FetchLicenseText("mit", p) + case licenseOptions[1]: + return files.FetchLicenseText("apache-2.0", p) + case licenseOptions[2]: + return files.FetchLicenseText("gpl-3.0", p) + default: + fmt.Println("Skipping LICENSE.") + } + return nil +} - // Create README.md - readmePath := filepath.Join(dir, "README.md") - if _, err := os.Stat(readmePath); err == nil { +func ensureReadme(dir, repoName string) error { + p := filepath.Join(dir, "README.md") + if _, err := os.Stat(p); err == nil { if !quiet { fmt.Println("README.md already exists, skipping.") } - } else if os.IsNotExist(err) { - if !quiet { - fmt.Println("Creating README.md...") - } - if err := files.CreateReadme(repoName, description, readmePath); err != nil { - return fmt.Errorf("could not create README.md: %w", err) - } - } else { - return fmt.Errorf("could not access %s: %w", readmePath, err) + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("could not access %s: %w", p, err) } + if !quiet { + fmt.Println("Creating README.md...") + } + if err := files.CreateReadme(repoName, description, p); err != nil { + return fmt.Errorf("could not create README.md: %w", err) + } + return nil +} - // Initialize git repo if needed +func ensureGitRepo(dir string) error { gitDir := filepath.Join(dir, ".git") if _, err := os.Stat(gitDir); err == nil { if !quiet { fmt.Println("Git repository already exists, skipping init.") } - } else if os.IsNotExist(err) { - if !quiet { - fmt.Println("Initializing git repository...") - } - if err := repo.InitGitRepo(dir); err != nil { - return fmt.Errorf("could not initialize git repository: %w", err) - } - } else { + return nil + } else if !os.IsNotExist(err) { return fmt.Errorf("could not access %s: %w", gitDir, err) } + if !quiet { + fmt.Println("Initializing git repository...") + } + if err := repo.InitGitRepo(dir); err != nil { + return fmt.Errorf("could not initialize git repository: %w", err) + } + return nil +} - // Determine visibility +func createRemoteAndPush(dir, repoName string) error { visibility := "public" if private { visibility = "private" } - - // Create GitHub repo (sets remote origin, no push) if !quiet { fmt.Printf("Creating GitHub repository %s...\n", repoName) } if err := repo.CreateGitHubRepo(dir, repoName, visibility, description); err != nil { return fmt.Errorf("could not create GitHub repository: %w", err) } - - // Commit and push with user-supplied branch and message if !quiet { fmt.Printf("Committing and pushing to branch %q...\n", branch) } if err := repo.CommitAndPush(dir, branch, message); err != nil { return fmt.Errorf("could not commit and push: %w", err) } - if !quiet { - // Fetch the authenticated gh username for the correct URL ghUser := ghAuthenticatedUser() if ghUser != "" { fmt.Printf("✓ Done! Repository created: %s/%s\n", ghUser, repoName) diff --git a/internal/files/gitignore.go b/internal/files/gitignore.go index f1648b4..86901b4 100644 --- a/internal/files/gitignore.go +++ b/internal/files/gitignore.go @@ -5,29 +5,68 @@ import ( "io" "net/http" "os" + "strings" "time" ) -// FetchGitignore downloads a language-specific .gitignore template from GitHub or creates a minimal one. +// languageAliases maps common lowercase inputs to the exact filename used in +// github.com/github/gitignore (without the .gitignore extension). +var languageAliases = map[string]string{ + "javascript": "Node", + "js": "Node", + "typescript": "Node", + "ts": "Node", + "golang": "Go", + "py": "Python", + "rb": "Ruby", + "rs": "Rust", + "cs": "CSharp", + "csharp": "CSharp", + "c++": "C++", + "cpp": "C++", + "sh": "Shell", + "bash": "Shell", +} + +// NormalizeLanguage converts a user-supplied language string to the exact +// casing used by the github/gitignore repository. +func NormalizeLanguage(lang string) string { + lower := strings.ToLower(strings.TrimSpace(lang)) + if alias, ok := languageAliases[lower]; ok { + return alias + } + // Title-case as a best-effort for everything else (e.g. "python" → "Python") + if len(lower) > 0 { + return strings.ToUpper(lower[:1]) + lower[1:] + } + return lang +} + +// FetchGitignore downloads a language-specific .gitignore template from the +// github/gitignore repository. Returns an error if the template is not found +// rather than silently falling back to a minimal file. func FetchGitignore(language, dest string) error { - if language != "" { - url := fmt.Sprintf("https://raw.githubusercontent.com/github/gitignore/master/%s.gitignore", language) - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Get(url) - if err == nil { - defer func() { _ = resp.Body.Close() }() - } - if err == nil && resp.StatusCode == http.StatusOK { - f, err := os.Create(dest) - if err != nil { - return err - } - defer func() { _ = f.Close() }() - _, err = io.Copy(f, resp.Body) - return err - } + normalized := NormalizeLanguage(language) + url := fmt.Sprintf("https://raw.githubusercontent.com/github/gitignore/master/%s.gitignore", normalized) + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("could not fetch .gitignore template for %q: %w", language, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("no .gitignore template found for language %q (tried %q)", language, normalized) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d fetching .gitignore template for %q", resp.StatusCode, language) + } + + f, err := os.Create(dest) + if err != nil { + return err } - // Fallback to minimal .gitignore - minimal := ".DS_Store\n.idea/\n.vscode/\n*.swp\n" - return os.WriteFile(dest, []byte(minimal), 0644) + defer func() { _ = f.Close() }() + _, err = io.Copy(f, resp.Body) + return err } From 2652f0db36d073e0ef38e79a302451ce464d436b Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:31:08 +0100 Subject: [PATCH 09/10] fix: validate message flag, cleanup orphaned repo on push failure, guard empty language --- cmd/root.go | 10 ++++++++++ internal/files/gitignore.go | 5 ++++- internal/repo/repo.go | 6 ++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index a2fe12f..4d72a98 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,6 +40,10 @@ var rootCmd = &cobra.Command{ if branch == "" { return fmt.Errorf("flag --branch cannot be empty") } + message = strings.TrimSpace(message) + if message == "" { + return fmt.Errorf("flag --message cannot be empty") + } return nil }, Long: `gitstart automates project setup, git init, and GitHub repo creation. @@ -282,6 +286,12 @@ func createRemoteAndPush(dir, repoName string) error { fmt.Printf("Committing and pushing to branch %q...\n", branch) } if err := repo.CommitAndPush(dir, branch, message); err != nil { + // Clean up the orphaned remote repo so the user can retry cleanly + if cleanupErr := repo.DeleteGitHubRepo(repoName); cleanupErr != nil { + fmt.Fprintf(os.Stderr, "warning: could not delete orphaned repository %q: %v\n", repoName, cleanupErr) + } else if !quiet { + fmt.Fprintf(os.Stderr, "note: deleted orphaned repository %q after push failure\n", repoName) + } return fmt.Errorf("could not commit and push: %w", err) } if !quiet { diff --git a/internal/files/gitignore.go b/internal/files/gitignore.go index 86901b4..3d04d2e 100644 --- a/internal/files/gitignore.go +++ b/internal/files/gitignore.go @@ -39,7 +39,7 @@ func NormalizeLanguage(lang string) string { if len(lower) > 0 { return strings.ToUpper(lower[:1]) + lower[1:] } - return lang + return "" } // FetchGitignore downloads a language-specific .gitignore template from the @@ -47,6 +47,9 @@ func NormalizeLanguage(lang string) string { // rather than silently falling back to a minimal file. func FetchGitignore(language, dest string) error { normalized := NormalizeLanguage(language) + if normalized == "" { + return fmt.Errorf("language cannot be empty") + } url := fmt.Sprintf("https://raw.githubusercontent.com/github/gitignore/master/%s.gitignore", normalized) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Get(url) diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 245dd1d..3a11018 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -56,6 +56,12 @@ func CommitAndPush(dir, branch, message string) error { return nil } +// DeleteGitHubRepo deletes a GitHub repository using the gh CLI. +// Used for cleanup when a subsequent step fails after repo creation. +func DeleteGitHubRepo(repoName string) error { + return runCmd(".", remoteCmdTimeout, "gh", "repo", "delete", repoName, "--yes") +} + // CreateGitHubRepo creates a GitHub repository using the gh CLI and sets the remote origin, // but does not push. Call CommitAndPush afterwards to stage, commit, and push. func CreateGitHubRepo(dir, repoName, visibility, description string) error { From d415519a25383554b94d039045c1c904b1beb87c Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:59:40 +0100 Subject: [PATCH 10/10] fix: use main branch for gitignore URL, write atomically via temp file --- internal/files/gitignore.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/files/gitignore.go b/internal/files/gitignore.go index 3d04d2e..677b48d 100644 --- a/internal/files/gitignore.go +++ b/internal/files/gitignore.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "time" ) @@ -50,7 +51,7 @@ func FetchGitignore(language, dest string) error { if normalized == "" { return fmt.Errorf("language cannot be empty") } - url := fmt.Sprintf("https://raw.githubusercontent.com/github/gitignore/master/%s.gitignore", normalized) + url := fmt.Sprintf("https://raw.githubusercontent.com/github/gitignore/main/%s.gitignore", normalized) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Get(url) if err != nil { @@ -65,11 +66,19 @@ func FetchGitignore(language, dest string) error { return fmt.Errorf("unexpected status %d fetching .gitignore template for %q", resp.StatusCode, language) } - f, err := os.Create(dest) + tmp, err := os.CreateTemp(filepath.Dir(dest), ".gitignore-*") if err != nil { return err } - defer func() { _ = f.Close() }() - _, err = io.Copy(f, resp.Body) - return err + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if _, err := io.Copy(tmp, resp.Body); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, dest) }