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..4d72a98 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,12 +1,19 @@ package cmd import ( + "context" "fmt" "os" + "os/exec" + "path/filepath" "runtime/debug" "strconv" + "strings" + "time" + "github.com/shinokada/gitstart/internal/files" "github.com/shinokada/gitstart/internal/prompts" + "github.com/shinokada/gitstart/internal/repo" "github.com/spf13/cobra" ) @@ -29,6 +36,14 @@ var rootCmd = &cobra.Command{ if private && public { return fmt.Errorf("flags --private and --public are mutually exclusive") } + branch = strings.TrimSpace(branch) + 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. @@ -65,19 +80,28 @@ 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 } - // 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 +135,189 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Minimal output") } +func run() error { + // Resolve directory to an absolute, clean path + dir := directory + 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) + + 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) + } + + 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.") + } + return nil + } + p := filepath.Join(dir, ".gitignore") + if _, err := os.Stat(p); err == nil { + if !quiet { + fmt.Println(".gitignore already exists, skipping.") + } + 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 +} + +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.") + } + 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 +} + +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.") + } + 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 +} + +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.") + } + 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 +} + +func createRemoteAndPush(dir, repoName string) error { + visibility := "public" + if private { + visibility = "private" + } + 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) + } + if !quiet { + 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 { + ghUser := ghAuthenticatedUser() + if ghUser != "" { + fmt.Printf("✓ Done! Repository created: %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. +// A 5-second timeout guards against the CLI hanging on network or auth issues. +func ghAuthenticatedUser() string { + 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(string(out)) +} + func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) diff --git a/internal/files/gitignore.go b/internal/files/gitignore.go index f1648b4..677b48d 100644 --- a/internal/files/gitignore.go +++ b/internal/files/gitignore.go @@ -5,29 +5,80 @@ import ( "io" "net/http" "os" + "path/filepath" + "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 "" +} + +// 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 - } - } - // Fallback to minimal .gitignore - minimal := ".DS_Store\n.idea/\n.vscode/\n*.swp\n" - return os.WriteFile(dest, []byte(minimal), 0644) + normalized := NormalizeLanguage(language) + if normalized == "" { + return fmt.Errorf("language cannot be empty") + } + 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 { + 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) + } + + tmp, err := os.CreateTemp(filepath.Dir(dest), ".gitignore-*") + if err != nil { + 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) } diff --git a/internal/repo/repo.go b/internal/repo/repo.go index b8eb989..3a11018 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -56,9 +56,16 @@ func CommitAndPush(dir, branch, message string) error { return nil } -// CreateGitHubRepo creates a GitHub repository using the gh CLI. +// 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 { - 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":