From cd5c2b841431c17ea88cbbbd01404ed9fe3d1a80 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:39:09 +0100 Subject: [PATCH 1/6] feat: add v1.2.0 features for post-framework-starter workflow - Auto-detect language from project marker files (go.mod, package.json, Cargo.toml, composer.json, Gemfile, pubspec.yaml, pom.xml, etc.) when -l is not provided; prints detected language to output - Add --no-license flag to skip LICENSE creation without quiet mode - Add --no-readme flag to skip README.md creation without quiet mode - Add --post-framework flag (implies --no-license --no-readme, enables language auto-detection and existing-branch detection) - Auto-detect active branch from .git/HEAD when --branch is not explicitly set, honouring branches created by framework starters - Add DetectLanguage() in internal/files/gitignore.go - Add DetectCurrentBranch() in internal/repo/repo.go - Add tests: internal/files/detect_test.go (13 cases), internal/repo/repo_branch_test.go (5 cases) - Add Makefile targets: test-smoke, test-clean - Add planning/feature-planning.md for v1.2.0 and v1.3.0 roadmap - Update dry-run output to reflect all new flags and detected values --- Makefile | 23 +++- cmd/root.go | 216 +++++++++++++++++++++++------- internal/files/detect_test.go | 104 ++++++++++++++ internal/files/gitignore.go | 30 +++++ internal/repo/repo.go | 19 +++ internal/repo/repo_branch_test.go | 62 +++++++++ 6 files changed, 405 insertions(+), 49 deletions(-) create mode 100644 internal/files/detect_test.go create mode 100644 internal/repo/repo_branch_test.go diff --git a/Makefile b/Makefile index b371fa3..058490d 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 ci +.PHONY: all build test clean clean-cache clean-all run lint lint-fix coverage coverage-ci install ci test-clean test-smoke # Default target all: build @@ -50,3 +50,24 @@ ci: clean-all lint test build # Install the CLI tool install: go install . + +# Remove temporary smoke-test directories created under /tmp +test-clean: + rm -rf /tmp/fake-svelte /tmp/fake-repo /tmp/smoke-node /tmp/smoke-go /tmp/smoke-svelte + @echo "Cleaned up /tmp smoke-test directories." + +# Dry-run smoke tests for all v1.2.0 features (no GitHub required) +test-smoke: install + @echo "--- Test: --no-license ---" + gitstart -d /tmp/smoke-node --no-license --dry-run + @echo "--- Test: --no-readme ---" + gitstart -d /tmp/smoke-node --no-readme --dry-run + @echo "--- Test: --post-framework (Node auto-detect) ---" + mkdir -p /tmp/fake-svelte && touch /tmp/fake-svelte/package.json + gitstart -d /tmp/fake-svelte --post-framework --dry-run + @echo "--- Test: branch auto-detection ---" + mkdir -p /tmp/fake-repo/.git && echo 'ref: refs/heads/develop' > /tmp/fake-repo/.git/HEAD + touch /tmp/fake-repo/package.json + gitstart -d /tmp/fake-repo --post-framework --dry-run + @echo "--- Cleaning up ---" + $(MAKE) test-clean diff --git a/cmd/root.go b/cmd/root.go index 4d72a98..9f93c43 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,43 +61,99 @@ More examples: gitstart -d test-repo --dry-run gitstart -d automated-repo -q cd my-existing-project && gitstart -d . -l javascript --description "My existing JavaScript project" + +After a framework starter: + npx sv create my-app && cd my-app && gitstart -d . --post-framework + npm create vite@latest my-app && cd my-app && gitstart -d . --post-framework + composer create-project laravel/laravel my-app && cd my-app && gitstart -d . --post-framework `, Run: func(cmd *cobra.Command, args []string) { if directory == "" { _ = cmd.Help() return } + + // --post-framework implies --no-license and --no-readme; it also + // enables language auto-detection and existing-branch detection. + if postFramework { + noLicense = true + noReadme = true + } + if dryRun { + // Resolve directory for dry-run display. + dir := resolvDir(directory) + repoName := filepath.Base(dir) + + detectedLang := "" + if language == "" && postFramework { + detectedLang = files.DetectLanguage(dir) + } + effectiveLang := language + if effectiveLang == "" { + effectiveLang = detectedLang + } + + detectedBranch := "" + if !cmd.Flags().Changed("branch") { + detectedBranch = repo.DetectCurrentBranch(dir) + } + effectiveBranch := branch + if detectedBranch != "" { + effectiveBranch = detectedBranch + } + prompts.DryRunPrompt("[OPTIONS]") - prompts.DryRunPrompt(" Directory: " + directory) - prompts.DryRunPrompt(" Language: " + language) - prompts.DryRunPrompt(" Branch: " + branch) + prompts.DryRunPrompt(" Directory: " + dir) + prompts.DryRunPrompt(" Repo name: " + repoName) + if language != "" { + prompts.DryRunPrompt(" Language (explicit): " + language) + } else if detectedLang != "" { + prompts.DryRunPrompt(" Language (auto-detected): " + detectedLang) + } else { + prompts.DryRunPrompt(" Language: (none)") + } + prompts.DryRunPrompt(" Branch: " + effectiveBranch) prompts.DryRunPrompt(" Commit message: " + message) prompts.DryRunPrompt(" Private: " + strconv.FormatBool(private)) prompts.DryRunPrompt(" Public: " + strconv.FormatBool(public)) prompts.DryRunPrompt(" Description: " + description) prompts.DryRunPrompt(" Quiet: " + strconv.FormatBool(quiet)) + prompts.DryRunPrompt(" No-license: " + strconv.FormatBool(noLicense)) + prompts.DryRunPrompt(" No-readme: " + strconv.FormatBool(noReadme)) + prompts.DryRunPrompt(" Post-framework: " + strconv.FormatBool(postFramework)) prompts.DryRunPrompt(" Dry-run: true") prompts.DryRunPrompt("[ACTIONS]") prompts.DryRunPrompt("Would create project directory if needed") - if strings.TrimSpace(language) != "" { - prompts.DryRunPrompt("Would create .gitignore for language: " + language) + if effectiveLang != "" { + prompts.DryRunPrompt("Would create .gitignore for language: " + effectiveLang) } else { - prompts.DryRunPrompt("Would skip .gitignore (no language specified)") + prompts.DryRunPrompt("Would skip .gitignore (no language specified or detected)") } - if quiet { + if noLicense { + prompts.DryRunPrompt("Would skip LICENSE creation (--no-license / --post-framework)") + } else 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") + if noReadme { + prompts.DryRunPrompt("Would skip README.md creation (--no-readme / --post-framework)") + } else { + 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 add remote origin and push to branch: " + effectiveBranch) prompts.DryRunPrompt("No actions will be performed in dry-run mode.") return } + + // Capture whether --branch was explicitly set so run() can decide + // whether to honour an existing repo's branch instead. + branchExplicit = cmd.Flags().Changed("branch") + if err := run(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) @@ -105,7 +161,25 @@ More examples: }, } -var dryRun bool +var ( + dryRun bool + directory string + language string + branch string + message string + private bool + public bool + description string + quiet bool + noLicense bool + noReadme bool + postFramework bool + + // branchExplicit records whether --branch was explicitly provided by the + // user. Set in the Run closure so it is available inside run(). + branchExplicit bool +) + var versionCmd = &cobra.Command{ Use: "version", Short: "Show gitstart version", @@ -113,39 +187,37 @@ var versionCmd = &cobra.Command{ fmt.Println("gitstart version", getVersion()) }, } -var directory string -var language string -var branch string -var message string -var private bool -var public bool -var description string -var quiet bool func init() { rootCmd.AddCommand(versionCmd) rootCmd.PersistentFlags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview actions without making changes") - rootCmd.PersistentFlags().StringVarP(&directory, "directory", "d", "", "Project directory name (use . for current directory)") - rootCmd.PersistentFlags().StringVarP(&language, "language", "l", "", "Programming language for .gitignore") - rootCmd.PersistentFlags().StringVarP(&branch, "branch", "b", "main", "Branch name (default: main)") + rootCmd.PersistentFlags().StringVarP(&directory, "directory", "d", "", "Project directory name or path (use . for current directory)") + rootCmd.PersistentFlags().StringVarP(&language, "language", "l", "", "Programming language for .gitignore (auto-detected if omitted)") + rootCmd.PersistentFlags().StringVarP(&branch, "branch", "b", "main", "Branch name (default: main; auto-detected from existing repo if not set)") rootCmd.PersistentFlags().StringVarP(&message, "message", "m", "Initial commit", "Commit message") rootCmd.PersistentFlags().BoolVarP(&private, "private", "p", false, "Create a private repository (default: public)") rootCmd.PersistentFlags().BoolVarP(&public, "public", "P", false, "Create a public repository") rootCmd.PersistentFlags().StringVar(&description, "description", "", "Repository description") rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Minimal output") + rootCmd.PersistentFlags().BoolVar(&noLicense, "no-license", false, "Skip LICENSE file creation") + rootCmd.PersistentFlags().BoolVar(&noReadme, "no-readme", false, "Skip README.md creation") + rootCmd.PersistentFlags().BoolVar(&postFramework, "post-framework", false, "Optimised for use after a framework starter (implies --no-license --no-readme, enables auto-detection)") } -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) +// resolvDir converts the directory flag value to an absolute, clean path. +func resolvDir(dir string) string { + if filepath.IsAbs(dir) { + return filepath.Clean(dir) } - dir = filepath.Clean(dir) + wd, err := os.Getwd() + if err != nil { + return filepath.Clean(dir) + } + return filepath.Clean(filepath.Join(wd, dir)) +} + +func run() error { + dir := resolvDir(directory) repoName := filepath.Base(dir) if err := files.CreateProjectDir(dir); err != nil { @@ -174,14 +246,10 @@ func run() error { } 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 .gitignore already exists, always skip — even if we would have + // auto-detected a language — to avoid overwriting framework-generated files. if _, err := os.Stat(p); err == nil { if !quiet { fmt.Println(".gitignore already exists, skipping.") @@ -190,16 +258,42 @@ func ensureGitignore(dir string) error { } else if !os.IsNotExist(err) { return fmt.Errorf("could not access %s: %w", p, err) } + + lang := strings.TrimSpace(language) + + // Auto-detect if no language was explicitly provided. + if lang == "" { + detected := files.DetectLanguage(dir) + if detected != "" { + if !quiet { + fmt.Printf("Auto-detected language: %s. Creating .gitignore...\n", detected) + } + lang = detected + } + } + + if lang == "" { + if !quiet { + fmt.Println("No language specified or detected, skipping .gitignore creation.") + } + return nil + } + if !quiet { fmt.Println("Creating .gitignore...") } - if err := files.FetchGitignore(lang, p); err != nil { - return err - } - return nil + return files.FetchGitignore(lang, p) } func ensureLicense(dir string) error { + // Respect --no-license (also set by --post-framework). + if noLicense { + if !quiet { + fmt.Println("Skipping LICENSE creation (--no-license).") + } + return nil + } + p := filepath.Join(dir, "LICENSE") if _, err := os.Stat(p); err == nil { if !quiet { @@ -209,10 +303,12 @@ func ensureLicense(dir string) error { } 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 + // Skip interactive prompt in quiet/non-interactive mode. return nil } + licenseOptions := []string{ "mit: Simple and permissive", "apache-2.0: Community-friendly", @@ -234,6 +330,14 @@ func ensureLicense(dir string) error { } func ensureReadme(dir, repoName string) error { + // Respect --no-readme (also set by --post-framework). + if noReadme { + if !quiet { + fmt.Println("Skipping README.md creation (--no-readme).") + } + return nil + } + p := filepath.Join(dir, "README.md") if _, err := os.Stat(p); err == nil { if !quiet { @@ -243,6 +347,7 @@ func ensureReadme(dir, repoName string) error { } else if !os.IsNotExist(err) { return fmt.Errorf("could not access %s: %w", p, err) } + if !quiet { fmt.Println("Creating README.md...") } @@ -271,6 +376,18 @@ func ensureGitRepo(dir string) error { return nil } +// effectiveBranch returns the branch to push to. If the user did not explicitly +// pass --branch and an existing git repo is detected, we read the active branch +// from .git/HEAD so we honour whatever the framework starter (or the user) set. +func effectiveBranch(dir string) string { + if !branchExplicit { + if detected := repo.DetectCurrentBranch(dir); detected != "" { + return detected + } + } + return branch +} + func createRemoteAndPush(dir, repoName string) error { visibility := "public" if private { @@ -282,11 +399,13 @@ func createRemoteAndPush(dir, repoName string) error { if err := repo.CreateGitHubRepo(dir, repoName, visibility, description); err != nil { return fmt.Errorf("could not create GitHub repository: %w", err) } + + pushBranch := effectiveBranch(dir) if !quiet { - fmt.Printf("Committing and pushing to branch %q...\n", branch) + fmt.Printf("Committing and pushing to branch %q...\n", pushBranch) } - if err := repo.CommitAndPush(dir, branch, message); err != nil { - // Clean up the orphaned remote repo so the user can retry cleanly + if err := repo.CommitAndPush(dir, pushBranch, 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 { @@ -305,8 +424,9 @@ func createRemoteAndPush(dir, repoName string) error { 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. +// 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() diff --git a/internal/files/detect_test.go b/internal/files/detect_test.go new file mode 100644 index 0000000..fee6ee4 --- /dev/null +++ b/internal/files/detect_test.go @@ -0,0 +1,104 @@ +package files + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectLanguage(t *testing.T) { + tests := []struct { + name string + markers []string // files to create in the temp dir + expected string + }{ + { + name: "Go project", + markers: []string{"go.mod"}, + expected: "Go", + }, + { + name: "Node project via package.json", + markers: []string{"package.json"}, + expected: "Node", + }, + { + name: "Python project via requirements.txt", + markers: []string{"requirements.txt"}, + expected: "Python", + }, + { + name: "Python project via pyproject.toml", + markers: []string{"pyproject.toml"}, + expected: "Python", + }, + { + name: "Rust project", + markers: []string{"Cargo.toml"}, + expected: "Rust", + }, + { + name: "PHP project", + markers: []string{"composer.json"}, + expected: "PHP", + }, + { + name: "Ruby project", + markers: []string{"Gemfile"}, + expected: "Ruby", + }, + { + name: "Dart project", + markers: []string{"pubspec.yaml"}, + expected: "Dart", + }, + { + name: "Java project via pom.xml", + markers: []string{"pom.xml"}, + expected: "Java", + }, + { + name: "Java project via build.gradle", + markers: []string{"build.gradle"}, + expected: "Java", + }, + { + name: "empty directory returns empty string", + markers: []string{}, + expected: "", + }, + { + name: "unknown markers return empty string", + markers: []string{"CMakeLists.txt", "main.cpp"}, + expected: "", + }, + { + // Go takes priority over Node in the marker list ordering. + name: "Go wins over Node when both markers present", + markers: []string{"go.mod", "package.json"}, + expected: "Go", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for _, f := range tc.markers { + if err := os.WriteFile(filepath.Join(dir, f), []byte(""), 0644); err != nil { + t.Fatalf("could not create marker file %s: %v", f, err) + } + } + got := DetectLanguage(dir) + if got != tc.expected { + t.Errorf("DetectLanguage() = %q, want %q", got, tc.expected) + } + }) + } +} + +func TestDetectLanguage_NonExistentDir(t *testing.T) { + got := DetectLanguage("/non/existent/directory/xyz123") + if got != "" { + t.Errorf("expected empty string for non-existent dir, got %q", got) + } +} diff --git a/internal/files/gitignore.go b/internal/files/gitignore.go index 677b48d..433af01 100644 --- a/internal/files/gitignore.go +++ b/internal/files/gitignore.go @@ -10,6 +10,36 @@ import ( "time" ) +// languageMarkers maps known project marker filenames to a gitignore language. +// The first matching marker wins, so order within each entry does not matter +// but entries higher in the slice take priority if multiple languages match. +var languageMarkers = []struct { + files []string + language string +}{ + {files: []string{"go.mod"}, language: "Go"}, + {files: []string{"Cargo.toml"}, language: "Rust"}, + {files: []string{"pubspec.yaml"}, language: "Dart"}, + {files: []string{"composer.json"}, language: "PHP"}, + {files: []string{"Gemfile"}, language: "Ruby"}, + {files: []string{"pom.xml", "build.gradle", "build.gradle.kts"}, language: "Java"}, + {files: []string{"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"}, language: "Python"}, + {files: []string{"package.json"}, language: "Node"}, +} + +// DetectLanguage inspects dir for well-known project marker files and returns +// the inferred gitignore language name, or "" if nothing is recognised. +func DetectLanguage(dir string) string { + for _, entry := range languageMarkers { + for _, marker := range entry.files { + if _, err := os.Stat(filepath.Join(dir, marker)); err == nil { + return entry.language + } + } + } + return "" +} + // languageAliases maps common lowercase inputs to the exact filename used in // github.com/github/gitignore (without the .gitignore extension). var languageAliases = map[string]string{ diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 3a11018..ebcedf1 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "fmt" + "os" "os/exec" + "path/filepath" "strings" "time" ) @@ -34,6 +36,23 @@ func InitGitRepo(dir string) error { return runCmd(dir, localCmdTimeout, "git", "init") } +// DetectCurrentBranch reads the active branch from an existing .git/HEAD file. +// Returns "" if .git/HEAD is absent, unreadable, or in a detached-HEAD state. +func DetectCurrentBranch(dir string) string { + headPath := filepath.Join(dir, ".git", "HEAD") + data, err := os.ReadFile(headPath) + if err != nil { + return "" + } + // .git/HEAD contains "ref: refs/heads/\n" when on a branch. + line := strings.TrimSpace(string(data)) + const prefix = "ref: refs/heads/" + if !strings.HasPrefix(line, prefix) { + return "" + } + return strings.TrimPrefix(line, prefix) +} + // CommitAndPush stages all files, commits, and pushes to the remote repository. func CommitAndPush(dir, branch, message string) error { type commandSpec struct { diff --git a/internal/repo/repo_branch_test.go b/internal/repo/repo_branch_test.go new file mode 100644 index 0000000..4c8382a --- /dev/null +++ b/internal/repo/repo_branch_test.go @@ -0,0 +1,62 @@ +package repo + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectCurrentBranch(t *testing.T) { + tests := []struct { + name string + head string // content of .git/HEAD; empty means don't create the file + expected string + }{ + { + name: "standard branch", + head: "ref: refs/heads/main\n", + expected: "main", + }, + { + name: "non-default branch", + head: "ref: refs/heads/develop\n", + expected: "develop", + }, + { + name: "branch with slashes", + head: "ref: refs/heads/feat/my-feature\n", + expected: "feat/my-feature", + }, + { + name: "detached HEAD returns empty string", + head: "abc123def456abc123def456abc123def456abc12\n", + expected: "", + }, + { + name: "no .git directory returns empty string", + head: "", // will not create .git/HEAD + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + + if tc.head != "" { + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatalf("could not create .git dir: %v", err) + } + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte(tc.head), 0644); err != nil { + t.Fatalf("could not write .git/HEAD: %v", err) + } + } + + got := DetectCurrentBranch(dir) + if got != tc.expected { + t.Errorf("DetectCurrentBranch() = %q, want %q", got, tc.expected) + } + }) + } +} From 50374c2d99fed93bc8256837933dcc3e892e2c07 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:58:50 +0100 Subject: [PATCH 2/6] fix: address three code review issues from v1.2.0 - Rename resolvDir to resolveDir (typo fix) - Fix dry-run language detection to always auto-detect when -l is omitted, not only when --post-framework is set; aligns dry-run output with actual ensureGitignore behaviour - Fix composer.json marker: map to "Composer" not "PHP" since PHP.gitignore does not exist in github/gitignore; update detect_test.go expected value accordingly --- cmd/root.go | 10 +++++----- internal/files/detect_test.go | 4 ++-- internal/files/gitignore.go | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 9f93c43..07e6962 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -82,11 +82,11 @@ After a framework starter: if dryRun { // Resolve directory for dry-run display. - dir := resolvDir(directory) + dir := resolveDir(directory) repoName := filepath.Base(dir) detectedLang := "" - if language == "" && postFramework { + if language == "" { detectedLang = files.DetectLanguage(dir) } effectiveLang := language @@ -204,8 +204,8 @@ func init() { rootCmd.PersistentFlags().BoolVar(&postFramework, "post-framework", false, "Optimised for use after a framework starter (implies --no-license --no-readme, enables auto-detection)") } -// resolvDir converts the directory flag value to an absolute, clean path. -func resolvDir(dir string) string { +// resolveDir converts the directory flag value to an absolute, clean path. +func resolveDir(dir string) string { if filepath.IsAbs(dir) { return filepath.Clean(dir) } @@ -217,7 +217,7 @@ func resolvDir(dir string) string { } func run() error { - dir := resolvDir(directory) + dir := resolveDir(directory) repoName := filepath.Base(dir) if err := files.CreateProjectDir(dir); err != nil { diff --git a/internal/files/detect_test.go b/internal/files/detect_test.go index fee6ee4..db7fbcd 100644 --- a/internal/files/detect_test.go +++ b/internal/files/detect_test.go @@ -38,9 +38,9 @@ func TestDetectLanguage(t *testing.T) { expected: "Rust", }, { - name: "PHP project", + name: "Composer (PHP) project", markers: []string{"composer.json"}, - expected: "PHP", + expected: "Composer", }, { name: "Ruby project", diff --git a/internal/files/gitignore.go b/internal/files/gitignore.go index 433af01..0ed0432 100644 --- a/internal/files/gitignore.go +++ b/internal/files/gitignore.go @@ -20,7 +20,7 @@ var languageMarkers = []struct { {files: []string{"go.mod"}, language: "Go"}, {files: []string{"Cargo.toml"}, language: "Rust"}, {files: []string{"pubspec.yaml"}, language: "Dart"}, - {files: []string{"composer.json"}, language: "PHP"}, + {files: []string{"composer.json"}, language: "Composer"}, {files: []string{"Gemfile"}, language: "Ruby"}, {files: []string{"pom.xml", "build.gradle", "build.gradle.kts"}, language: "Java"}, {files: []string{"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"}, language: "Python"}, From 4860b1bfe3a4ae71cf1697ce99683618f61eabb8 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:13:12 +0100 Subject: [PATCH 3/6] docs: README and docs/README update --- README.md | 100 ++++++++++++++++++++++++++++++++++++++++++------- docs/README.md | 100 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 174 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 954617a..2c91408 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,12 @@ Gitstart automates creating a GitHub repository. It will: -- Create `.gitignore` if you provide a language +- Auto-detect project language and create `.gitignore` (or use `-l` to specify) - Create a license file based on your choice - Create a new repository at GitHub.com (public or private) - Create a `README.md` file with the repository name - Initialize a git repository (if needed) +- Auto-detect the active branch from an existing repo - Add files and commit with a custom message - Add the remote and push - Support existing directories and projects @@ -84,22 +85,64 @@ cd existing_project gitstart -d . ``` +### After a Framework Starter + +gitstart works seamlessly after scaffolding tools like `npx sv create`, `npm create vite@latest`, or `composer create-project`. Use `--post-framework` to skip prompts for files the framework already created, while still auto-detecting the language and branch: + +```sh +npx sv create my-app && cd my-app && gitstart -d . --post-framework +npm create vite@latest my-app && cd my-app && gitstart -d . --post-framework +composer create-project laravel/laravel my-app && cd my-app && gitstart -d . --post-framework +npx nuxi@latest init my-app && cd my-app && gitstart -d . --post-framework +``` + +Or use quiet mode for a minimal one-liner: + +```sh +npx sv create my-app && cd my-app && gitstart -d . -q +``` + ### Options ``` -d, --directory DIRECTORY Directory name or path (use . for current directory) --l, --language LANGUAGE Programming language for .gitignore +-l, --language LANGUAGE Programming language for .gitignore (auto-detected if omitted) -p, --private Create a private repository (default: public) -P, --public Create a public repository --b, --branch BRANCH Branch name (default: main) +-b, --branch BRANCH Branch name (auto-detected from existing repo; default: main) -m, --message MESSAGE Initial commit message (default: "Initial commit") --description DESC Repository description + --no-license Skip LICENSE file creation + --no-readme Skip README.md creation + --post-framework Optimised for use after a framework starter + (implies --no-license --no-readme, enables auto-detection) -n, --dry-run Show what would happen without executing -q, --quiet Minimal output -h, --help Show help message version Show version ``` +### Language Auto-detection + +When `-l` is not provided and no `.gitignore` exists, gitstart inspects the project directory for well-known marker files and infers the language automatically: + +| Marker file(s) | Detected language | +|---|---| +| `go.mod` | Go | +| `Cargo.toml` | Rust | +| `pubspec.yaml` | Dart | +| `composer.json` | Composer (PHP) | +| `Gemfile` | Ruby | +| `pom.xml`, `build.gradle`, `build.gradle.kts` | Java | +| `requirements.txt`, `pyproject.toml`, `setup.py`, `setup.cfg` | Python | +| `package.json` | Node | + +If multiple markers are present the first match in the table above wins. You can always override auto-detection with `-l`. + +### Branch Auto-detection + +When `--branch` is not explicitly set and a `.git` directory already exists (e.g. created by a framework starter), gitstart reads the active branch from `.git/HEAD` and pushes to that branch instead of defaulting to `main`. Passing `--branch` explicitly always takes precedence. + ### Examples **Create a new repository:** @@ -127,6 +170,19 @@ gitstart -d my-app -m "First release" -b develop gitstart -d awesome-tool --description "An amazing CLI tool for developers" ``` +**Skip LICENSE and README (e.g. framework already created them):** +```sh +cd my-existing-project +gitstart -d . --no-license --no-readme +``` + +**Use --post-framework after a Svelte scaffold:** +```sh +npx sv create my-app +cd my-app +gitstart -d . --post-framework +``` + **Preview changes without executing (dry run):** ```sh gitstart -d test-repo --dry-run @@ -184,7 +240,7 @@ gitstart completion powershell >> $PROFILE ### Working with Existing Directories -**Empty directory:** Creates repository normally +**Empty directory:** Creates repository normally. **Directory with files but no git:** - Warns about existing files @@ -194,25 +250,25 @@ gitstart completion powershell >> $PROFILE **Directory with existing git repository:** - Detects existing `.git` folder +- Auto-detects the active branch from `.git/HEAD` - Adds remote to existing repository - Preserves git history **Existing LICENSE, README.md, or .gitignore:** -- Detects existing files -- Offers to append or skip -- Prevents accidental overwrites +- Detects existing files and skips them +- Use `--no-license` or `--no-readme` to explicitly suppress creation +- Use `--post-framework` to suppress both at once ### Interactive License Selection -When you run gitstart, you'll be prompted to select a license: +When you run gitstart without `--no-license`, `--post-framework`, or `-q`, you'll be prompted to select a license: ``` Select a license: -1) MIT: I want it simple and permissive. -2) Apache License 2.0: I need to work in a community. -3) GNU GPLv3: I care about sharing improvements. +1) mit: Simple and permissive +2) apache-2.0: Community-friendly +3) gpl-3.0: Share improvements 4) None -5) Quit ``` ## Error Handling @@ -220,7 +276,7 @@ Select a license: - **Automatic cleanup**: If repository creation fails, the remote repository is automatically deleted - **Validation checks**: Ensures all required tools are installed - **Auth verification**: Confirms you're logged in to GitHub -- **File conflict detection**: Warns about existing files before overwriting +- **File conflict detection**: Detects existing files and skips safely - **Detailed error messages**: Clear information about what went wrong and how to fix it ## About Licensing @@ -229,6 +285,24 @@ Read more about [Licensing](https://docs.github.com/en/free-pro-team@latest/rest ## Changelog +### Version 1.2.0 + +**New Features:** +- Auto-detect project language from marker files (`go.mod`, `package.json`, `Cargo.toml`, etc.) when `-l` is not provided +- `--no-license` flag to skip LICENSE creation without suppressing all output +- `--no-readme` flag to skip README.md creation without suppressing all output +- `--post-framework` flag: optimised mode for use after framework starters — implies `--no-license --no-readme` and enables language and branch auto-detection +- Auto-detect active branch from `.git/HEAD` when `--branch` is not explicitly set + +**Bug Fixes:** +- Fixed dry-run language detection to always auto-detect (not only when `--post-framework` is set) +- Fixed `composer.json` marker mapping from `PHP` to `Composer` (PHP.gitignore does not exist in github/gitignore) +- Renamed internal `resolvDir` to `resolveDir` (typo fix) + +### Version 1.1.0 + +- Added shell completion support (bash, zsh, fish, PowerShell) + ### Version 1.0.0 (2026) Gitstart is now rewritten in Go with full cross-platform support (macOS, Linux, Windows). diff --git a/docs/README.md b/docs/README.md index 954617a..2c91408 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,11 +14,12 @@ Gitstart automates creating a GitHub repository. It will: -- Create `.gitignore` if you provide a language +- Auto-detect project language and create `.gitignore` (or use `-l` to specify) - Create a license file based on your choice - Create a new repository at GitHub.com (public or private) - Create a `README.md` file with the repository name - Initialize a git repository (if needed) +- Auto-detect the active branch from an existing repo - Add files and commit with a custom message - Add the remote and push - Support existing directories and projects @@ -84,22 +85,64 @@ cd existing_project gitstart -d . ``` +### After a Framework Starter + +gitstart works seamlessly after scaffolding tools like `npx sv create`, `npm create vite@latest`, or `composer create-project`. Use `--post-framework` to skip prompts for files the framework already created, while still auto-detecting the language and branch: + +```sh +npx sv create my-app && cd my-app && gitstart -d . --post-framework +npm create vite@latest my-app && cd my-app && gitstart -d . --post-framework +composer create-project laravel/laravel my-app && cd my-app && gitstart -d . --post-framework +npx nuxi@latest init my-app && cd my-app && gitstart -d . --post-framework +``` + +Or use quiet mode for a minimal one-liner: + +```sh +npx sv create my-app && cd my-app && gitstart -d . -q +``` + ### Options ``` -d, --directory DIRECTORY Directory name or path (use . for current directory) --l, --language LANGUAGE Programming language for .gitignore +-l, --language LANGUAGE Programming language for .gitignore (auto-detected if omitted) -p, --private Create a private repository (default: public) -P, --public Create a public repository --b, --branch BRANCH Branch name (default: main) +-b, --branch BRANCH Branch name (auto-detected from existing repo; default: main) -m, --message MESSAGE Initial commit message (default: "Initial commit") --description DESC Repository description + --no-license Skip LICENSE file creation + --no-readme Skip README.md creation + --post-framework Optimised for use after a framework starter + (implies --no-license --no-readme, enables auto-detection) -n, --dry-run Show what would happen without executing -q, --quiet Minimal output -h, --help Show help message version Show version ``` +### Language Auto-detection + +When `-l` is not provided and no `.gitignore` exists, gitstart inspects the project directory for well-known marker files and infers the language automatically: + +| Marker file(s) | Detected language | +|---|---| +| `go.mod` | Go | +| `Cargo.toml` | Rust | +| `pubspec.yaml` | Dart | +| `composer.json` | Composer (PHP) | +| `Gemfile` | Ruby | +| `pom.xml`, `build.gradle`, `build.gradle.kts` | Java | +| `requirements.txt`, `pyproject.toml`, `setup.py`, `setup.cfg` | Python | +| `package.json` | Node | + +If multiple markers are present the first match in the table above wins. You can always override auto-detection with `-l`. + +### Branch Auto-detection + +When `--branch` is not explicitly set and a `.git` directory already exists (e.g. created by a framework starter), gitstart reads the active branch from `.git/HEAD` and pushes to that branch instead of defaulting to `main`. Passing `--branch` explicitly always takes precedence. + ### Examples **Create a new repository:** @@ -127,6 +170,19 @@ gitstart -d my-app -m "First release" -b develop gitstart -d awesome-tool --description "An amazing CLI tool for developers" ``` +**Skip LICENSE and README (e.g. framework already created them):** +```sh +cd my-existing-project +gitstart -d . --no-license --no-readme +``` + +**Use --post-framework after a Svelte scaffold:** +```sh +npx sv create my-app +cd my-app +gitstart -d . --post-framework +``` + **Preview changes without executing (dry run):** ```sh gitstart -d test-repo --dry-run @@ -184,7 +240,7 @@ gitstart completion powershell >> $PROFILE ### Working with Existing Directories -**Empty directory:** Creates repository normally +**Empty directory:** Creates repository normally. **Directory with files but no git:** - Warns about existing files @@ -194,25 +250,25 @@ gitstart completion powershell >> $PROFILE **Directory with existing git repository:** - Detects existing `.git` folder +- Auto-detects the active branch from `.git/HEAD` - Adds remote to existing repository - Preserves git history **Existing LICENSE, README.md, or .gitignore:** -- Detects existing files -- Offers to append or skip -- Prevents accidental overwrites +- Detects existing files and skips them +- Use `--no-license` or `--no-readme` to explicitly suppress creation +- Use `--post-framework` to suppress both at once ### Interactive License Selection -When you run gitstart, you'll be prompted to select a license: +When you run gitstart without `--no-license`, `--post-framework`, or `-q`, you'll be prompted to select a license: ``` Select a license: -1) MIT: I want it simple and permissive. -2) Apache License 2.0: I need to work in a community. -3) GNU GPLv3: I care about sharing improvements. +1) mit: Simple and permissive +2) apache-2.0: Community-friendly +3) gpl-3.0: Share improvements 4) None -5) Quit ``` ## Error Handling @@ -220,7 +276,7 @@ Select a license: - **Automatic cleanup**: If repository creation fails, the remote repository is automatically deleted - **Validation checks**: Ensures all required tools are installed - **Auth verification**: Confirms you're logged in to GitHub -- **File conflict detection**: Warns about existing files before overwriting +- **File conflict detection**: Detects existing files and skips safely - **Detailed error messages**: Clear information about what went wrong and how to fix it ## About Licensing @@ -229,6 +285,24 @@ Read more about [Licensing](https://docs.github.com/en/free-pro-team@latest/rest ## Changelog +### Version 1.2.0 + +**New Features:** +- Auto-detect project language from marker files (`go.mod`, `package.json`, `Cargo.toml`, etc.) when `-l` is not provided +- `--no-license` flag to skip LICENSE creation without suppressing all output +- `--no-readme` flag to skip README.md creation without suppressing all output +- `--post-framework` flag: optimised mode for use after framework starters — implies `--no-license --no-readme` and enables language and branch auto-detection +- Auto-detect active branch from `.git/HEAD` when `--branch` is not explicitly set + +**Bug Fixes:** +- Fixed dry-run language detection to always auto-detect (not only when `--post-framework` is set) +- Fixed `composer.json` marker mapping from `PHP` to `Composer` (PHP.gitignore does not exist in github/gitignore) +- Renamed internal `resolvDir` to `resolveDir` (typo fix) + +### Version 1.1.0 + +- Added shell completion support (bash, zsh, fish, PowerShell) + ### Version 1.0.0 (2026) Gitstart is now rewritten in Go with full cross-platform support (macOS, Linux, Windows). From 2e415a9a510e6c713f24764607c18698dac14ec8 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:40:45 +0100 Subject: [PATCH 4/6] fix: address three code review issues in v1.2.0 - Stop mutating Cobra-bound flag vars: replace the postFramework block that overwrote noLicense/noReadme with derived locals effNoLicense and effNoReadme (|| postFramework); thread them as parameters through run(), ensureLicense(), and ensureReadme() to avoid corrupted state across multiple in-process Execute() calls - Fix --post-framework help text: remove "enables auto-detection" since language and branch auto-detection are unconditional, not gated by this flag - Fix branch divergence on newly-initialised repos: InitGitRepo now accepts a branch parameter and passes -b to git init, making the initial branch deterministic regardless of system init.defaultBranch; effectiveBranch() is resolved once before ensureGitRepo() and passed through to both init and push, so dry-run output always matches the actual run; update TestInitGitRepo to pass branch argument --- cmd/root.go | 58 ++++++++++++++++++++------------------ internal/repo/repo.go | 8 ++++-- internal/repo/repo_test.go | 2 +- 3 files changed, 37 insertions(+), 31 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 07e6962..c84a01e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -73,12 +73,11 @@ After a framework starter: return } - // --post-framework implies --no-license and --no-readme; it also - // enables language auto-detection and existing-branch detection. - if postFramework { - noLicense = true - noReadme = true - } + // Derive effective skip flags without mutating the Cobra-bound vars. + // Mutating noLicense/noReadme directly would corrupt state across + // multiple in-process Execute() calls (e.g. in tests). + effNoLicense := noLicense || postFramework + effNoReadme := noReadme || postFramework if dryRun { // Resolve directory for dry-run display. @@ -119,8 +118,8 @@ After a framework starter: prompts.DryRunPrompt(" Public: " + strconv.FormatBool(public)) prompts.DryRunPrompt(" Description: " + description) prompts.DryRunPrompt(" Quiet: " + strconv.FormatBool(quiet)) - prompts.DryRunPrompt(" No-license: " + strconv.FormatBool(noLicense)) - prompts.DryRunPrompt(" No-readme: " + strconv.FormatBool(noReadme)) + prompts.DryRunPrompt(" No-license: " + strconv.FormatBool(effNoLicense)) + prompts.DryRunPrompt(" No-readme: " + strconv.FormatBool(effNoReadme)) prompts.DryRunPrompt(" Post-framework: " + strconv.FormatBool(postFramework)) prompts.DryRunPrompt(" Dry-run: true") prompts.DryRunPrompt("[ACTIONS]") @@ -130,14 +129,14 @@ After a framework starter: } else { prompts.DryRunPrompt("Would skip .gitignore (no language specified or detected)") } - if noLicense { + if effNoLicense { prompts.DryRunPrompt("Would skip LICENSE creation (--no-license / --post-framework)") } else if quiet { prompts.DryRunPrompt("Would skip LICENSE creation (quiet mode)") } else { prompts.DryRunPrompt("Would prompt for and create LICENSE file") } - if noReadme { + if effNoReadme { prompts.DryRunPrompt("Would skip README.md creation (--no-readme / --post-framework)") } else { prompts.DryRunPrompt("Would create README.md with project name and description") @@ -154,7 +153,7 @@ After a framework starter: // whether to honour an existing repo's branch instead. branchExplicit = cmd.Flags().Changed("branch") - if err := run(); err != nil { + if err := run(effNoLicense, effNoReadme); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } @@ -201,7 +200,7 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Minimal output") rootCmd.PersistentFlags().BoolVar(&noLicense, "no-license", false, "Skip LICENSE file creation") rootCmd.PersistentFlags().BoolVar(&noReadme, "no-readme", false, "Skip README.md creation") - rootCmd.PersistentFlags().BoolVar(&postFramework, "post-framework", false, "Optimised for use after a framework starter (implies --no-license --no-readme, enables auto-detection)") + rootCmd.PersistentFlags().BoolVar(&postFramework, "post-framework", false, "Optimised for use after a framework starter (implies --no-license --no-readme)") } // resolveDir converts the directory flag value to an absolute, clean path. @@ -216,7 +215,7 @@ func resolveDir(dir string) string { return filepath.Clean(filepath.Join(wd, dir)) } -func run() error { +func run(effNoLicense, effNoReadme bool) error { dir := resolveDir(directory) repoName := filepath.Base(dir) @@ -230,16 +229,20 @@ func run() error { if err := ensureGitignore(dir); err != nil { return err } - if err := ensureLicense(dir); err != nil { + if err := ensureLicense(dir, effNoLicense); err != nil { return err } - if err := ensureReadme(dir, repoName); err != nil { + if err := ensureReadme(dir, repoName, effNoReadme); err != nil { return err } - if err := ensureGitRepo(dir); err != nil { + // resolvedBranch is determined before git init so that a newly-created + // repo always gets the exact branch the user expects (or the default). + // This keeps dry-run output consistent with the actual run. + resolvedBranch := effectiveBranch(dir) + if err := ensureGitRepo(dir, resolvedBranch); err != nil { return err } - if err := createRemoteAndPush(dir, repoName); err != nil { + if err := createRemoteAndPush(dir, repoName, resolvedBranch); err != nil { return err } return nil @@ -285,9 +288,9 @@ func ensureGitignore(dir string) error { return files.FetchGitignore(lang, p) } -func ensureLicense(dir string) error { +func ensureLicense(dir string, effNoLicense bool) error { // Respect --no-license (also set by --post-framework). - if noLicense { + if effNoLicense { if !quiet { fmt.Println("Skipping LICENSE creation (--no-license).") } @@ -329,9 +332,9 @@ func ensureLicense(dir string) error { return nil } -func ensureReadme(dir, repoName string) error { +func ensureReadme(dir, repoName string, effNoReadme bool) error { // Respect --no-readme (also set by --post-framework). - if noReadme { + if effNoReadme { if !quiet { fmt.Println("Skipping README.md creation (--no-readme).") } @@ -357,7 +360,7 @@ func ensureReadme(dir, repoName string) error { return nil } -func ensureGitRepo(dir string) error { +func ensureGitRepo(dir, branch string) error { gitDir := filepath.Join(dir, ".git") if _, err := os.Stat(gitDir); err == nil { if !quiet { @@ -370,15 +373,17 @@ func ensureGitRepo(dir string) error { if !quiet { fmt.Println("Initializing git repository...") } - if err := repo.InitGitRepo(dir); err != nil { + if err := repo.InitGitRepo(dir, branch); err != nil { return fmt.Errorf("could not initialize git repository: %w", err) } return nil } // effectiveBranch returns the branch to push to. If the user did not explicitly -// pass --branch and an existing git repo is detected, we read the active branch -// from .git/HEAD so we honour whatever the framework starter (or the user) set. +// pass --branch and a pre-existing git repo is detected, we read the active +// branch from .git/HEAD so we honour whatever the framework starter set. +// For newly-created repos there is no .git/HEAD yet, so this falls back to +// the branch flag default, which is then passed explicitly to git init. func effectiveBranch(dir string) string { if !branchExplicit { if detected := repo.DetectCurrentBranch(dir); detected != "" { @@ -388,7 +393,7 @@ func effectiveBranch(dir string) string { return branch } -func createRemoteAndPush(dir, repoName string) error { +func createRemoteAndPush(dir, repoName, pushBranch string) error { visibility := "public" if private { visibility = "private" @@ -400,7 +405,6 @@ func createRemoteAndPush(dir, repoName string) error { return fmt.Errorf("could not create GitHub repository: %w", err) } - pushBranch := effectiveBranch(dir) if !quiet { fmt.Printf("Committing and pushing to branch %q...\n", pushBranch) } diff --git a/internal/repo/repo.go b/internal/repo/repo.go index ebcedf1..4d9f447 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -31,9 +31,11 @@ func runCmd(dir string, timeout time.Duration, args ...string) error { return nil } -// InitGitRepo initializes a git repository in the given directory. -func InitGitRepo(dir string) error { - return runCmd(dir, localCmdTimeout, "git", "init") +// InitGitRepo initializes a git repository in the given directory with the +// given initial branch name. Passing an explicit branch makes the result +// deterministic regardless of the system-level init.defaultBranch setting. +func InitGitRepo(dir, branch string) error { + return runCmd(dir, localCmdTimeout, "git", "init", "-b", branch) } // DetectCurrentBranch reads the active branch from an existing .git/HEAD file. diff --git a/internal/repo/repo_test.go b/internal/repo/repo_test.go index 6794f9f..a41c6cb 100644 --- a/internal/repo/repo_test.go +++ b/internal/repo/repo_test.go @@ -9,7 +9,7 @@ import ( func TestInitGitRepo(t *testing.T) { tmpDir := t.TempDir() - err := InitGitRepo(tmpDir) + err := InitGitRepo(tmpDir, "main") if err != nil { t.Fatalf("expected no error, got %v", err) } From e32237df3053195b7ffd67a6178718d1a531d122 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:37:03 +0100 Subject: [PATCH 5/6] fix: update remaining InitGitRepo call sites to pass branch argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix repo_commit_test.go: pass "main" as branch to InitGitRepo, missed when the signature was updated from InitGitRepo(dir) to InitGitRepo(dir, branch) in the previous commit fix: address five code review issues in v1.2.0 - Fix branchExplicit stale state across Execute() calls: remove package-level branchExplicit var; derive branchWasSet as a local in Run() via cmd.Flags().Changed("branch") and thread it through run(), effectiveBranch(), and the dry-run path — same pattern as effNoLicense/effNoReadme - Fix DetectCurrentBranch for worktree/submodule repos: add resolveGitDir() which follows the "gitdir: " pointer when .git is a file rather than a directory; DetectCurrentBranch now calls resolveGitDir() before reading HEAD - Add TestDetectCurrentBranch_WorktreeFile to verify .git file indirection is resolved correctly - Assert branch name in TestInitGitRepo via DetectCurrentBranch so the test proves the -b flag was honoured, not just that .git exists - Remove "enables auto-detection" from --post-framework help text, options table, and v1.2.0 changelog in README.md and docs/README.md - Capitalise GitHub/gitignore in both READMEs - Add "text" language identifier to bare fenced code blocks in both READMEs to fix markdownlint MD040 warnings --- README.md | 10 +++---- cmd/root.go | 33 +++++++++++------------- docs/README.md | 10 +++---- internal/repo/repo.go | 43 +++++++++++++++++++++++++++---- internal/repo/repo_branch_test.go | 22 ++++++++++++++++ internal/repo/repo_commit_test.go | 2 +- internal/repo/repo_test.go | 3 +++ 7 files changed, 89 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 2c91408..20eb463 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ npx sv create my-app && cd my-app && gitstart -d . -q ### Options -``` +```text -d, --directory DIRECTORY Directory name or path (use . for current directory) -l, --language LANGUAGE Programming language for .gitignore (auto-detected if omitted) -p, --private Create a private repository (default: public) @@ -115,7 +115,7 @@ npx sv create my-app && cd my-app && gitstart -d . -q --no-license Skip LICENSE file creation --no-readme Skip README.md creation --post-framework Optimised for use after a framework starter - (implies --no-license --no-readme, enables auto-detection) + (implies --no-license --no-readme) -n, --dry-run Show what would happen without executing -q, --quiet Minimal output -h, --help Show help message @@ -263,7 +263,7 @@ gitstart completion powershell >> $PROFILE When you run gitstart without `--no-license`, `--post-framework`, or `-q`, you'll be prompted to select a license: -``` +```text Select a license: 1) mit: Simple and permissive 2) apache-2.0: Community-friendly @@ -291,12 +291,12 @@ Read more about [Licensing](https://docs.github.com/en/free-pro-team@latest/rest - Auto-detect project language from marker files (`go.mod`, `package.json`, `Cargo.toml`, etc.) when `-l` is not provided - `--no-license` flag to skip LICENSE creation without suppressing all output - `--no-readme` flag to skip README.md creation without suppressing all output -- `--post-framework` flag: optimised mode for use after framework starters — implies `--no-license --no-readme` and enables language and branch auto-detection +- `--post-framework` flag: optimised mode for use after framework starters — implies `--no-license --no-readme` - Auto-detect active branch from `.git/HEAD` when `--branch` is not explicitly set **Bug Fixes:** - Fixed dry-run language detection to always auto-detect (not only when `--post-framework` is set) -- Fixed `composer.json` marker mapping from `PHP` to `Composer` (PHP.gitignore does not exist in github/gitignore) +- Fixed `composer.json` marker mapping from `PHP` to `Composer` (PHP.gitignore does not exist in GitHub/gitignore) - Renamed internal `resolvDir` to `resolveDir` (typo fix) ### Version 1.1.0 diff --git a/cmd/root.go b/cmd/root.go index c84a01e..d4f06a5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -93,8 +93,9 @@ After a framework starter: effectiveLang = detectedLang } + branchWasSetDry := cmd.Flags().Changed("branch") detectedBranch := "" - if !cmd.Flags().Changed("branch") { + if !branchWasSetDry { detectedBranch = repo.DetectCurrentBranch(dir) } effectiveBranch := branch @@ -149,11 +150,11 @@ After a framework starter: return } - // Capture whether --branch was explicitly set so run() can decide - // whether to honour an existing repo's branch instead. - branchExplicit = cmd.Flags().Changed("branch") + // Derive whether --branch was explicitly set locally so the value + // is never stale across repeated in-process Execute() calls. + branchWasSet := cmd.Flags().Changed("branch") - if err := run(effNoLicense, effNoReadme); err != nil { + if err := run(effNoLicense, effNoReadme, branchWasSet); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } @@ -173,10 +174,6 @@ var ( noLicense bool noReadme bool postFramework bool - - // branchExplicit records whether --branch was explicitly provided by the - // user. Set in the Run closure so it is available inside run(). - branchExplicit bool ) var versionCmd = &cobra.Command{ @@ -215,7 +212,7 @@ func resolveDir(dir string) string { return filepath.Clean(filepath.Join(wd, dir)) } -func run(effNoLicense, effNoReadme bool) error { +func run(effNoLicense, effNoReadme, branchWasSet bool) error { dir := resolveDir(directory) repoName := filepath.Base(dir) @@ -238,7 +235,7 @@ func run(effNoLicense, effNoReadme bool) error { // resolvedBranch is determined before git init so that a newly-created // repo always gets the exact branch the user expects (or the default). // This keeps dry-run output consistent with the actual run. - resolvedBranch := effectiveBranch(dir) + resolvedBranch := effectiveBranch(dir, branchWasSet) if err := ensureGitRepo(dir, resolvedBranch); err != nil { return err } @@ -379,13 +376,13 @@ func ensureGitRepo(dir, branch string) error { return nil } -// effectiveBranch returns the branch to push to. If the user did not explicitly -// pass --branch and a pre-existing git repo is detected, we read the active -// branch from .git/HEAD so we honour whatever the framework starter set. -// For newly-created repos there is no .git/HEAD yet, so this falls back to -// the branch flag default, which is then passed explicitly to git init. -func effectiveBranch(dir string) string { - if !branchExplicit { +// effectiveBranch returns the branch to push to. branchWasSet reports whether +// the user explicitly passed --branch on this invocation. If not, and a +// pre-existing repo is found, we read the active branch from git so we honour +// whatever the framework starter set. For newly-created repos there is no HEAD +// yet, so we fall back to the branch flag default and pass it to git init. +func effectiveBranch(dir string, branchWasSet bool) string { + if !branchWasSet { if detected := repo.DetectCurrentBranch(dir); detected != "" { return detected } diff --git a/docs/README.md b/docs/README.md index 2c91408..20eb463 100644 --- a/docs/README.md +++ b/docs/README.md @@ -104,7 +104,7 @@ npx sv create my-app && cd my-app && gitstart -d . -q ### Options -``` +```text -d, --directory DIRECTORY Directory name or path (use . for current directory) -l, --language LANGUAGE Programming language for .gitignore (auto-detected if omitted) -p, --private Create a private repository (default: public) @@ -115,7 +115,7 @@ npx sv create my-app && cd my-app && gitstart -d . -q --no-license Skip LICENSE file creation --no-readme Skip README.md creation --post-framework Optimised for use after a framework starter - (implies --no-license --no-readme, enables auto-detection) + (implies --no-license --no-readme) -n, --dry-run Show what would happen without executing -q, --quiet Minimal output -h, --help Show help message @@ -263,7 +263,7 @@ gitstart completion powershell >> $PROFILE When you run gitstart without `--no-license`, `--post-framework`, or `-q`, you'll be prompted to select a license: -``` +```text Select a license: 1) mit: Simple and permissive 2) apache-2.0: Community-friendly @@ -291,12 +291,12 @@ Read more about [Licensing](https://docs.github.com/en/free-pro-team@latest/rest - Auto-detect project language from marker files (`go.mod`, `package.json`, `Cargo.toml`, etc.) when `-l` is not provided - `--no-license` flag to skip LICENSE creation without suppressing all output - `--no-readme` flag to skip README.md creation without suppressing all output -- `--post-framework` flag: optimised mode for use after framework starters — implies `--no-license --no-readme` and enables language and branch auto-detection +- `--post-framework` flag: optimised mode for use after framework starters — implies `--no-license --no-readme` - Auto-detect active branch from `.git/HEAD` when `--branch` is not explicitly set **Bug Fixes:** - Fixed dry-run language detection to always auto-detect (not only when `--post-framework` is set) -- Fixed `composer.json` marker mapping from `PHP` to `Composer` (PHP.gitignore does not exist in github/gitignore) +- Fixed `composer.json` marker mapping from `PHP` to `Composer` (PHP.gitignore does not exist in GitHub/gitignore) - Renamed internal `resolvDir` to `resolveDir` (typo fix) ### Version 1.1.0 diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 4d9f447..50b3854 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -38,15 +38,48 @@ func InitGitRepo(dir, branch string) error { return runCmd(dir, localCmdTimeout, "git", "init", "-b", branch) } -// DetectCurrentBranch reads the active branch from an existing .git/HEAD file. -// Returns "" if .git/HEAD is absent, unreadable, or in a detached-HEAD state. +// resolveGitDir returns the path to the real .git directory for dir. +// When .git is a file (worktrees, submodules), it contains a "gitdir: " +// pointer that must be followed to find the actual git directory. +func resolveGitDir(dir string) string { + gitPath := filepath.Join(dir, ".git") + info, err := os.Stat(gitPath) + if err != nil { + return "" + } + if info.IsDir() { + return gitPath + } + // .git is a file — read the "gitdir: " pointer. + data, err := os.ReadFile(gitPath) + if err != nil { + return "" + } + line := strings.TrimSpace(string(data)) + const filePrefix = "gitdir: " + if !strings.HasPrefix(line, filePrefix) { + return "" + } + pointed := strings.TrimPrefix(line, filePrefix) + if !filepath.IsAbs(pointed) { + pointed = filepath.Join(dir, pointed) + } + return filepath.Clean(pointed) +} + +// DetectCurrentBranch reads the active branch from an existing git repo in dir. +// It resolves .git file indirection (worktrees, submodules) before reading HEAD. +// Returns "" if no repo is found, HEAD is unreadable, or HEAD is detached. func DetectCurrentBranch(dir string) string { - headPath := filepath.Join(dir, ".git", "HEAD") - data, err := os.ReadFile(headPath) + gitDir := resolveGitDir(dir) + if gitDir == "" { + return "" + } + data, err := os.ReadFile(filepath.Join(gitDir, "HEAD")) if err != nil { return "" } - // .git/HEAD contains "ref: refs/heads/\n" when on a branch. + // HEAD contains "ref: refs/heads/\n" when on a named branch. line := strings.TrimSpace(string(data)) const prefix = "ref: refs/heads/" if !strings.HasPrefix(line, prefix) { diff --git a/internal/repo/repo_branch_test.go b/internal/repo/repo_branch_test.go index 4c8382a..9a63d6c 100644 --- a/internal/repo/repo_branch_test.go +++ b/internal/repo/repo_branch_test.go @@ -60,3 +60,25 @@ func TestDetectCurrentBranch(t *testing.T) { }) } } + +func TestDetectCurrentBranch_WorktreeFile(t *testing.T) { + // Simulate a worktree/submodule where .git is a file containing a + // "gitdir: " pointer rather than being a directory. + worktreeDir := t.TempDir() + realGitDir := t.TempDir() + + // Write the real HEAD into the separate git directory. + if err := os.WriteFile(filepath.Join(realGitDir, "HEAD"), []byte("ref: refs/heads/feature\n"), 0644); err != nil { + t.Fatalf("could not write HEAD: %v", err) + } + + // Write the .git file pointer in the worktree directory (absolute path). + gitFile := filepath.Join(worktreeDir, ".git") + if err := os.WriteFile(gitFile, []byte("gitdir: "+realGitDir+"\n"), 0644); err != nil { + t.Fatalf("could not write .git file: %v", err) + } + + if got := DetectCurrentBranch(worktreeDir); got != "feature" { + t.Errorf("DetectCurrentBranch() = %q, want %q", "feature", got) + } +} diff --git a/internal/repo/repo_commit_test.go b/internal/repo/repo_commit_test.go index 96061dd..78d5b13 100644 --- a/internal/repo/repo_commit_test.go +++ b/internal/repo/repo_commit_test.go @@ -10,7 +10,7 @@ func TestCommitAndPush(t *testing.T) { dir := t.TempDir() // Initialize git repo - if err := InitGitRepo(dir); err != nil { + if err := InitGitRepo(dir, "main"); err != nil { t.Fatalf("failed to init git repo: %v", err) } diff --git a/internal/repo/repo_test.go b/internal/repo/repo_test.go index a41c6cb..0a5260d 100644 --- a/internal/repo/repo_test.go +++ b/internal/repo/repo_test.go @@ -17,4 +17,7 @@ func TestInitGitRepo(t *testing.T) { if _, err := os.Stat(filepath.Join(tmpDir, ".git")); err != nil { t.Fatalf("expected .git directory to exist, got error: %v", err) } + if got := DetectCurrentBranch(tmpDir); got != "main" { + t.Fatalf("expected branch %q after init, got %q", "main", got) + } } From d2c144ed2ae883577da11be2c4e41fa91ffebd34 Mon Sep 17 00:00:00 2001 From: Shinichi Okada <147320+shinokada@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:04:26 +0100 Subject: [PATCH 6/6] fix: correct swapped arguments in worktree branch test error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t.Errorf format is "got %q, want %q" but args were reversed — "feature" (want) was passed first and got second, producing a misleading failure message on test failure --- internal/repo/repo_branch_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/repo/repo_branch_test.go b/internal/repo/repo_branch_test.go index 9a63d6c..3a11873 100644 --- a/internal/repo/repo_branch_test.go +++ b/internal/repo/repo_branch_test.go @@ -79,6 +79,6 @@ func TestDetectCurrentBranch_WorktreeFile(t *testing.T) { } if got := DetectCurrentBranch(worktreeDir); got != "feature" { - t.Errorf("DetectCurrentBranch() = %q, want %q", "feature", got) + t.Errorf("DetectCurrentBranch() = %q, want %q", got, "feature") } }