diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771b589..767f4ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,19 +124,20 @@ jobs: go-version: '1.24' cache: true - - name: Build binaries + - name: Build binary run: | - go build -v -o bin/git-get ./cmd/get - go build -v -o bin/git-list ./cmd/list + go build -v -o bin/git-get ./cmd/ - - name: Test binaries + - name: Test binary and symlink behavior run: | ./bin/git-get --version + # Test symlink functionality + ln -sf git-get bin/git-list ./bin/git-list --version - name: Upload build artifacts uses: actions/upload-artifact@v4 with: - name: binaries + name: binary path: bin/ retention-days: 30 diff --git a/.github/workflows/release-simple.yml b/.github/workflows/release-simple.yml deleted file mode 100644 index c01804e..0000000 --- a/.github/workflows/release-simple.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: release - -on: - push: - tags: - - '*' - -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.24' - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 - with: - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5250431..b0dbd12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,6 @@ on: permissions: contents: write security-events: write - id-token: write # For SLSA provenance jobs: validate: @@ -64,15 +63,4 @@ jobs: version: latest args: release --clean env: - GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }} - - provenance: - name: Generate SLSA Provenance - needs: release - if: startsWith(github.ref, 'refs/tags/') - uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 - with: - base64-subjects: "${{ needs.release.outputs.hashes }}" - upload-assets: true - secrets: - registry-password: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }} \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index 517123f..55cf728 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -6,36 +6,39 @@ before: builds: - id: git-get - main: ./cmd/get/main.go + main: ./cmd/ binary: git-get ldflags: - -s -w - -X git-get/pkg/cfg.version={{.Version}} - - -X git-get/pkg/cfg.commit={{.Commit}} - - -X git-get/pkg/cfg.date={{.Date}} + - -X git-get/pkg/cfg.commit={{.Commit}} goos: - linux - - darwin - windows - - id: git-list - main: ./cmd/list/main.go - binary: git-list + goarch: + - amd64 + - arm64 + - arm + goarm: + - 7 + - id: git-get-macos + main: ./cmd/ + binary: git-get ldflags: - -s -w - -X git-get/pkg/cfg.version={{.Version}} - - -X git-get/pkg/cfg.commit={{.Commit}} - - -X git-get/pkg/cfg.date={{.Date}} + - -X git-get/pkg/cfg.commit={{.Commit}} goos: - - linux - darwin - - windows + goarch: + - amd64 + - arm64 archives: - id: archive ids: - git-get - - git-list - name_template: "git-get_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + name_template: "git-get_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}{{- if .Amd64 }}v{{ .Amd64 }}{{ end }}" formats: - tar.gz - zip @@ -43,6 +46,14 @@ archives: - goos: windows formats: [zip] # Don't include any additional files into the archives (such as README, CHANGELOG etc). + files: + - none* + - id: macos-archive + ids: + - git-get-macos + name_template: "git-get_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}{{- if .Amd64 }}v{{ .Amd64 }}{{ end }}" + formats: + - tar.gz files: - none* @@ -64,8 +75,10 @@ release: owner: grdl name: git-get -brews: +homebrew_casks: - name: git-get + ids: + - macos-archive repository: owner: grdl name: homebrew-tap @@ -73,13 +86,14 @@ brews: commit_author: name: Grzegorz Dlugoszewski email: git-get@grdl.dev - directory: Formula homepage: https://github.com/grdl/git-get/ description: Better way to clone, organize and manage multiple git repositories - test: | - system "git-get --version" - install: | - bin.install "git-get", "git-list" + url: + verified: github.com/grdl/git-get + hooks: + post: + install: | + system_command "/bin/ln", args: ["-sf", "#{staged_path}/git-get", "#{HOMEBREW_PREFIX}/bin/git-list"] nfpms: - id: packages @@ -94,6 +108,9 @@ nfpms: formats: - deb - rpm + scripts: + postinstall: "scripts/postinstall.sh" + preremove: "scripts/preremove.sh" scoops: - name: git-get @@ -110,3 +127,6 @@ scoops: homepage: "https://github.com/grdl/git-get" description: "Better way to clone, organize and manage multiple git repositories" license: MIT + post_install: [ + "New-Item -ItemType HardLink -Path \"$dir\\git-list.exe\" -Target \"$dir\\git-get.exe\" -Force | Out-Null" + ] diff --git a/README.md b/README.md index 5d871f2..ddfecb1 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,12 @@ A tool to clone, organize, and manage multiple Git repositories with an automati **git-get** solves the problem of manually organizing multiple Git repositories. Instead of scattered clones in random directories, it creates a clean, predictable directory structure based on repository URLs, similar to Go's `go get` command. -It provides two commands: -- **`git get`** - Clones repositories into an organized directory tree +It provides two commands through a single binary: +- **`git get`** - Clones repositories into an organized directory tree - **`git list`** - Shows the status of all your repositories at a glance +*Note: Both commands are provided by a single `git-get` binary that automatically detects how it was invoked (either directly or via symlink).* + ![Example](./docs/example.svg) ## Features @@ -56,51 +58,91 @@ It provides two commands: ## Prerequisites - Git 2.0+ installed and configured -- Go 1.19+ (only if building from source) +- Go 1.24+ (only if building from source) ## Installation ### macOS +**Option 1: Homebrew (Recommended)** ```bash brew install grdl/tap/git-get ``` +*This automatically installs both `git-get` and `git-list` commands.* + +**Option 2: Manual Installation** +1. Download the latest macOS `.tar.gz` file from [releases](https://github.com/grdl/git-get/releases/latest) +2. Extract and install: + ```bash + tar -xzf git-get_*_darwin_*.tar.gz + sudo mv git-get /usr/local/bin/ + sudo ln -sf /usr/local/bin/git-get /usr/local/bin/git-list + ``` ### Linux -**Option 1: Package managers** +**Option 1: Package Managers (Recommended)** ```bash -# Download .deb or .rpm from releases -wget https://github.com/grdl/git-get/releases/latest/download/git-get_linux_amd64.deb -sudo dpkg -i git-get_linux_amd64.deb +# Ubuntu/Debian - Download and install .deb package +wget https://github.com/grdl/git-get/releases/latest/download/git-get_*_linux_amd64.deb +sudo dpkg -i git-get_*_linux_amd64.deb + +# CentOS/RHEL/Fedora - Download and install .rpm package +wget https://github.com/grdl/git-get/releases/latest/download/git-get_*_linux_amd64.rpm +sudo rpm -i git-get_*_linux_amd64.rpm +``` +*Package installation automatically creates the `git-list` symlink.* + +**Option 2: Manual Installation** +```bash +# Download and extract +wget https://github.com/grdl/git-get/releases/latest/download/git-get_*_linux_amd64.tar.gz +tar -xzf git-get_*_linux_amd64.tar.gz + +# Install binary and create symlink +sudo mv git-get /usr/local/bin/ +sudo ln -sf /usr/local/bin/git-get /usr/local/bin/git-list ``` -**Option 2: Homebrew on Linux** +**Option 3: Homebrew on Linux** ```bash brew install grdl/tap/git-get ``` ### Windows -**Option 1: Download binary** -1. Download the latest `.zip` file from [releases](https://github.com/grdl/git-get/releases/latest) -2. Extract the binaries to a directory in your PATH - -**Option 2: Using Scoop** +**Option 1: Scoop (Recommended)** ```powershell -scoop bucket add git-get https://github.com/grdl/git-get +scoop bucket add grdl https://github.com/grdl/git-get scoop install git-get ``` +*This automatically creates both `git-get.exe` and `git-list.exe` commands.* + +**Option 2: Manual Installation** +1. Download the latest Windows `.zip` file from [releases](https://github.com/grdl/git-get/releases/latest) +2. Extract `git-get.exe` to a directory in your PATH +3. Create a copy or hard link for `git-list`: + ```powershell + # In the same directory as git-get.exe + Copy-Item git-get.exe git-list.exe + # OR create a hard link (requires admin privileges) + New-Item -ItemType HardLink -Path "git-list.exe" -Target "git-get.exe" + ``` ### Building from Source ```bash git clone https://github.com/grdl/git-get.git cd git-get -go build -o bin/ ./cmd/... +go build -o git-get ./cmd/ + +# Create symlink for git-list +ln -sf git-get git-list # Unix/Linux/macOS +# OR +copy git-get.exe git-list.exe # Windows ``` -Then add the `bin/` directory to your PATH. +**Note:** The single binary (`git-get`) automatically detects how it's invoked and behaves as either `git-get` or `git-list` accordingly. ## Quick Start diff --git a/cmd/get/main.go b/cmd/get.go similarity index 72% rename from cmd/get/main.go rename to cmd/get.go index 9b19fa5..d44926d 100644 --- a/cmd/get/main.go +++ b/cmd/get.go @@ -4,27 +4,28 @@ import ( "git-get/pkg" "git-get/pkg/cfg" "git-get/pkg/git" + "os" "github.com/spf13/cobra" "github.com/spf13/viper" ) -const example = ` git get grdl/git-get +const getExample = ` git get grdl/git-get git get https://github.com/grdl/git-get.git git get git@github.com:grdl/git-get.git git get -d path/to/dump/file` -var cmd = &cobra.Command{ - Use: "git get ", - Short: "Clone git repository into an automatically created directory tree based on the repo's URL.", - Example: example, - RunE: run, - Args: cobra.MaximumNArgs(1), // TODO: add custom validator - Version: cfg.Version(), - SilenceUsage: true, // We don't want to show usage on legit errors (eg, wrong path, repo already existing etc.) -} +func newGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "git get ", + Short: "Clone git repository into an automatically created directory tree based on the repo's URL.", + Example: getExample, + RunE: runGetCommand, + Args: cobra.MaximumNArgs(1), // TODO: add custom validator + Version: cfg.Version(), + SilenceUsage: true, // We don't want to show usage on legit errors (eg, wrong path, repo already existing etc.) + } -func init() { cmd.PersistentFlags().StringP(cfg.KeyBranch, "b", "", "Branch (or tag) to checkout after cloning.") cmd.PersistentFlags().StringP(cfg.KeyDefaultHost, "t", cfg.Defaults[cfg.KeyDefaultHost], "Host to use when doesn't have a specified host.") cmd.PersistentFlags().StringP(cfg.KeyDefaultScheme, "c", cfg.Defaults[cfg.KeyDefaultScheme], "Scheme to use when doesn't have a specified scheme.") @@ -41,10 +42,10 @@ func init() { viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot)) viper.BindPFlag(cfg.KeySkipHost, cmd.PersistentFlags().Lookup(cfg.KeySkipHost)) - cfg.Init(&git.ConfigGlobal{}) + return cmd } -func run(cmd *cobra.Command, args []string) error { +func runGetCommand(cmd *cobra.Command, args []string) error { var url string if len(args) > 0 { url = args[0] @@ -64,6 +65,17 @@ func run(cmd *cobra.Command, args []string) error { return pkg.Get(config) } -func main() { - cmd.Execute() +func runGet(args []string) { + // Initialize configuration + cfg.Init(&git.ConfigGlobal{}) + + // Create and execute the get command + cmd := newGetCommand() + + // Set args for cobra to parse + cmd.SetArgs(args) + + if err := cmd.Execute(); err != nil { + os.Exit(1) + } } diff --git a/cmd/list/main.go b/cmd/list.go similarity index 63% rename from cmd/list/main.go rename to cmd/list.go index 0bffae6..cf3b244 100644 --- a/cmd/list/main.go +++ b/cmd/list.go @@ -5,22 +5,23 @@ import ( "git-get/pkg" "git-get/pkg/cfg" "git-get/pkg/git" + "os" "strings" "github.com/spf13/cobra" "github.com/spf13/viper" ) -var cmd = &cobra.Command{ - Use: "git list", - Short: "List all repositories cloned by 'git get' and their status.", - RunE: run, - Args: cobra.NoArgs, - Version: cfg.Version(), - SilenceUsage: true, // We don't want to show usage on legit errors (eg, wrong path, repo already existing etc.) -} +func newListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "git list", + Short: "List all repositories cloned by 'git get' and their status.", + RunE: runListCommand, + Args: cobra.NoArgs, + Version: cfg.Version(), + SilenceUsage: true, // We don't want to show usage on legit errors (eg, wrong path, repo already existing etc.) + } -func init() { cmd.PersistentFlags().BoolP(cfg.KeyFetch, "f", false, "First fetch from remotes before listing repositories.") cmd.PersistentFlags().StringP(cfg.KeyOutput, "o", cfg.Defaults[cfg.KeyOutput], fmt.Sprintf("Output format. Allowed values: [%s].", strings.Join(cfg.AllowedOut, ", "))) cmd.PersistentFlags().StringP(cfg.KeyReposRoot, "r", cfg.Defaults[cfg.KeyReposRoot], "Path to repos root where repositories are cloned.") @@ -31,10 +32,10 @@ func init() { viper.BindPFlag(cfg.KeyOutput, cmd.PersistentFlags().Lookup(cfg.KeyOutput)) viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot)) - cfg.Init(&git.ConfigGlobal{}) + return cmd } -func run(cmd *cobra.Command, args []string) error { +func runListCommand(cmd *cobra.Command, args []string) error { cfg.Expand(cfg.KeyReposRoot) config := &pkg.ListCfg{ @@ -46,6 +47,17 @@ func run(cmd *cobra.Command, args []string) error { return pkg.List(config) } -func main() { - cmd.Execute() +func runList(args []string) { + // Initialize configuration + cfg.Init(&git.ConfigGlobal{}) + + // Create and execute the list command + cmd := newListCommand() + + // Set args for cobra to parse + cmd.SetArgs(args) + + if err := cmd.Execute(); err != nil { + os.Exit(1) + } } diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..01e5512 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "os" + "path/filepath" + "strings" +) + +func main() { + // This program behaves as a git subcommand (see https://git.github.io/htmldocs/howto/new-command.html) + // When added to PATH, git recognizes it as its subcommand and it can be invoked as "git get..." or "git list..." + // It can also be invoked as a regular binary with subcommands: "git-get get..." or "git-get list" + // The following flow detects the invokation method and runs the appropriate command. + + programName := filepath.Base(os.Args[0]) + + // Remove common executable extensions + programName = strings.TrimSuffix(programName, ".exe") + + // Determine which command to run based on program name or first argument + var command string + var args []string + + switch programName { + case "git-get": + // Check if first argument is a subcommand + if len(os.Args) > 1 && (os.Args[1] == "get" || os.Args[1] == "list") { + // Called as: git-get get or git-get list + command = os.Args[1] + args = os.Args[2:] + } else { + // Called as: git-get (default to get command) + command = "get" + args = os.Args[1:] + } + case "git-list": + // Called as: git-list (symlinked binary) + command = "list" + args = os.Args[1:] + default: + // Fallback: use first argument as command + if len(os.Args) > 1 { + command = os.Args[1] + args = os.Args[2:] + } else { + command = "get" + args = []string{} + } + } + + // Execute the appropriate command + switch command { + case "get": + runGet(args) + case "list": + runList(args) + default: + // Default to get command for unknown commands + runGet(os.Args[1:]) + } +} diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index 09f3cdb..a8f6be1 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -49,17 +49,20 @@ var AllowedOut = []string{OutDump, OutFlat, OutTree} var ( version string commit string - date string ) -// Version returns a string with version metadata: version number, git sha and build date. -// It returns "development" if version variables are not set during the build. +// Version returns a string with version metadata: version number and git commit. +// It returns "git-get development" if version variables are not set during the build. func Version() string { if version == "" { - return "development" + return "git-get development" } - return fmt.Sprintf("%s - revision %s built at %s", version, commit[:6], date) + if commit != "" { + return fmt.Sprintf("git-get %s (%s)", version, commit[:7]) + } + + return fmt.Sprintf("git-get %s", version) } // Gitconfig represents gitconfig file diff --git a/scripts/postinstall.sh b/scripts/postinstall.sh new file mode 100755 index 0000000..5af3cdf --- /dev/null +++ b/scripts/postinstall.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Create symlink for git-list command +ln -sf /usr/local/bin/git-get /usr/local/bin/git-list \ No newline at end of file diff --git a/scripts/preremove.sh b/scripts/preremove.sh new file mode 100755 index 0000000..b013566 --- /dev/null +++ b/scripts/preremove.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Remove symlink for git-list command +rm -f /usr/local/bin/git-list \ No newline at end of file