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/README.md b/README.md index 954617a..20eb463 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 . ``` -### Options +### 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 + +```text -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) -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: -``` +```text 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` +- 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/cmd/root.go b/cmd/root.go index 4d72a98..d4f06a5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,51 +61,121 @@ 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 } + + // 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. + dir := resolveDir(directory) + repoName := filepath.Base(dir) + + detectedLang := "" + if language == "" { + detectedLang = files.DetectLanguage(dir) + } + effectiveLang := language + if effectiveLang == "" { + effectiveLang = detectedLang + } + + branchWasSetDry := cmd.Flags().Changed("branch") + detectedBranch := "" + if !branchWasSetDry { + 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(effNoLicense)) + prompts.DryRunPrompt(" No-readme: " + strconv.FormatBool(effNoReadme)) + 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 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") } - prompts.DryRunPrompt("Would create README.md with project name and description") + 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") + } 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 } - if err := run(); err != nil { + + // 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, branchWasSet); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } }, } -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 +) + var versionCmd = &cobra.Command{ Use: "version", Short: "Show gitstart version", @@ -113,39 +183,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)") } -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) +// resolveDir converts the directory flag value to an absolute, clean path. +func resolveDir(dir string) string { + if filepath.IsAbs(dir) { + return filepath.Clean(dir) + } + wd, err := os.Getwd() + if err != nil { + return filepath.Clean(dir) } - dir = filepath.Clean(dir) + return filepath.Clean(filepath.Join(wd, dir)) +} + +func run(effNoLicense, effNoReadme, branchWasSet bool) error { + dir := resolveDir(directory) repoName := filepath.Base(dir) if err := files.CreateProjectDir(dir); err != nil { @@ -158,30 +226,30 @@ 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, branchWasSet) + 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 } 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 { +func ensureLicense(dir string, effNoLicense bool) error { + // Respect --no-license (also set by --post-framework). + if effNoLicense { + 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", @@ -233,7 +329,15 @@ 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 effNoReadme { + 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...") } @@ -252,7 +357,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 { @@ -265,13 +370,27 @@ 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 } -func createRemoteAndPush(dir, repoName string) error { +// 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 + } + } + return branch +} + +func createRemoteAndPush(dir, repoName, pushBranch string) error { visibility := "public" if private { visibility = "private" @@ -282,11 +401,12 @@ 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) } + 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 +425,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/docs/README.md b/docs/README.md index 954617a..20eb463 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 . ``` -### Options +### 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 + +```text -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) -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: -``` +```text 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` +- 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/internal/files/detect_test.go b/internal/files/detect_test.go new file mode 100644 index 0000000..db7fbcd --- /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: "Composer (PHP) project", + markers: []string{"composer.json"}, + expected: "Composer", + }, + { + 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..0ed0432 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: "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"}, + {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..50b3854 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" ) @@ -29,9 +31,61 @@ 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) +} + +// 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 { + gitDir := resolveGitDir(dir) + if gitDir == "" { + return "" + } + data, err := os.ReadFile(filepath.Join(gitDir, "HEAD")) + if err != nil { + return "" + } + // 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) { + return "" + } + return strings.TrimPrefix(line, prefix) } // CommitAndPush stages all files, commits, and pushes to the remote repository. diff --git a/internal/repo/repo_branch_test.go b/internal/repo/repo_branch_test.go new file mode 100644 index 0000000..3a11873 --- /dev/null +++ b/internal/repo/repo_branch_test.go @@ -0,0 +1,84 @@ +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) + } + }) + } +} + +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", got, "feature") + } +} 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 6794f9f..0a5260d 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) } @@ -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) + } }