Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 .
217 changes: 212 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
},
}

Expand Down Expand Up @@ -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)
Expand Down
93 changes: 72 additions & 21 deletions internal/files/gitignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading