├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── assets └── gitsnip-showcase.gif ├── cmd └── gitsnip │ └── main.go ├── go.mod ├── go.sum └── internal ├── app ├── app.go ├── downloader │ ├── factory.go │ ├── github_api.go │ ├── interface.go │ └── sparse_checkout.go ├── gitutil │ └── command.go └── model │ └── types.go ├── cli ├── root.go └── version.go ├── errors └── errors.go └── util ├── fs.go └── http.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v4 22 | with: 23 | go-version: ">=1.21.0" 24 | check-latest: true 25 | 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v6 28 | with: 29 | version: latest 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build targets 9 | *.test 10 | *.out 11 | bin/ 12 | pkg/ 13 | dist/ 14 | 15 | # Output of the go coverage tool, PID files 16 | *.cover 17 | *.pid 18 | 19 | # Dependency directories (Go 1.11+ uses modules) 20 | vendor/ 21 | 22 | # IDE/Editor files 23 | .vscode/ 24 | 25 | # OS generated files 26 | .DS_Store 27 | Thumbs.db -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | 3 | version: 2 4 | project_name: gitsnip 5 | 6 | before: 7 | hooks: 8 | - go mod tidy 9 | 10 | builds: 11 | - id: gitsnip 12 | env: 13 | - CGO_ENABLED=0 14 | goos: 15 | - linux 16 | - windows 17 | - darwin 18 | goarch: 19 | - amd64 20 | - arm64 21 | ldflags: 22 | - -s -w -X github.com/dagimg-dot/gitsnip/internal/cli.version={{.Version}} -X github.com/dagimg-dot/gitsnip/internal/cli.commit={{.Commit}} -X github.com/dagimg-dot/gitsnip/internal/cli.buildDate={{.Date}} -X github.com/dagimg-dot/gitsnip/internal/cli.builtBy=goreleaser 23 | main: ./cmd/gitsnip 24 | binary: gitsnip 25 | 26 | archives: 27 | - id: gitsnip 28 | ids: [gitsnip] 29 | formats: [tar.gz] 30 | format_overrides: 31 | - goos: windows 32 | formats: [zip] 33 | name_template: >- 34 | {{ .ProjectName }}_ 35 | {{- title .Os }}_ 36 | {{- if eq .Arch "amd64" }}x86_64 37 | {{- else if eq .Arch "386" }}i386 38 | {{- else }}{{ .Arch }}{{ end }} 39 | {{- if .Arm }}v{{ .Arm }}{{ end }} 40 | 41 | changelog: 42 | sort: asc 43 | filters: 44 | exclude: 45 | - "^docs:" 46 | - "^test:" 47 | - "^chore:" 48 | - Merge pull request 49 | - Merge branch 50 | 51 | checksum: 52 | name_template: "checksums.txt" 53 | 54 | release: 55 | github: 56 | owner: dagimg-dot 57 | name: gitsnip 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Dagim G. Astatkie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_NAME=gitsnip 2 | BINARY_PATH=bin/$(BINARY_NAME) 3 | CMD_PATH=./cmd/gitsnip 4 | 5 | GOFLAGS ?= 6 | TEST_FLAGS ?= -v 7 | 8 | VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo "dev") 9 | COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none") 10 | BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 11 | BUILD_BY ?= $(shell whoami) 12 | 13 | LDFLAGS := -s -w \ 14 | -X github.com/dagimg-dot/gitsnip/internal/cli.version=$(VERSION) \ 15 | -X github.com/dagimg-dot/gitsnip/internal/cli.commit=$(COMMIT) \ 16 | -X github.com/dagimg-dot/gitsnip/internal/cli.buildDate=$(BUILD_DATE) \ 17 | -X github.com/dagimg-dot/gitsnip/internal/cli.builtBy=$(BUILD_BY) 18 | 19 | .PHONY: all build clean run run-build lint lint-fix setup-hooks release 20 | 21 | all: build 22 | 23 | build: 24 | @echo "Building $(BINARY_NAME)..." 25 | @mkdir -p bin 26 | go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o $(BINARY_PATH) $(CMD_PATH) 27 | 28 | run: 29 | @echo "Running $(CMD_PATH) $(filter-out $@,$(MAKECMDGOALS))..." 30 | go run $(GOFLAGS) $(CMD_PATH) $(filter-out $@,$(MAKECMDGOALS)) 31 | 32 | run-build: build 33 | @echo "Running $(BINARY_PATH) $(filter-out $@,$(MAKECMDGOALS))..." 34 | ./$(BINARY_PATH) $(filter-out $@,$(MAKECMDGOALS)) 35 | 36 | run-binary: 37 | @echo "Running $(BINARY_PATH) $(filter-out $@,$(MAKECMDGOALS))..." 38 | ./$(BINARY_PATH) $(filter-out $@,$(MAKECMDGOALS)) 39 | 40 | clean: 41 | @echo "Cleaning..." 42 | rm -rf $(BINARY_PATH) 43 | rm -rf dist/* 44 | 45 | lint: 46 | @echo "Linting..." 47 | go fmt ./... 48 | 49 | release: 50 | @echo "Bumping version..." 51 | git tag v$(VERSION) 52 | git push origin v$(VERSION) 53 | 54 | local-release: 55 | @echo "Creating release for $(VERSION)..." 56 | @mkdir -p dist 57 | GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o dist/$(BINARY_NAME)_linux_$(VERSION)_$(shell go env GOARCH) $(CMD_PATH) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitsnip 2 | 3 | > A CLI tool to download specific folders from a git repository. 4 | 5 | ![showcase](./assets/gitsnip-showcase.gif) 6 | 7 | [![GitHub release](https://img.shields.io/github/v/release/dagimg-dot/gitsnip)](https://github.com/dagimg-dot/gitsnip/releases/latest) 8 | [![License](https://img.shields.io/github/license/dagimg-dot/gitsnip)](LICENSE) 9 | [![Downloads](https://img.shields.io/github/downloads/dagimg-dot/gitsnip/total)](https://github.com/dagimg-dot/gitsnip/releases) 10 | 11 | ## Features 12 | 13 | - 📂 Download specific folders from any Git repository 14 | - 🚀 Fast downloads using sparse checkout or API methods 15 | - 🔒 Support for private repositories 16 | - 🔧 Multiple download methods (API/sparse checkout) 17 | - 🔄 Branch selection support 18 | 19 | ## Installation 20 | 21 | ### Using [eget](https://github.com/zyedidia/eget) 22 | 23 | ```bash 24 | eget dagimg-dot/gitsnip 25 | ``` 26 | 27 | ### Using Go 28 | 29 | ```bash 30 | go install github.com/dagimg-dot/gitsnip/cmd/gitsnip@latest 31 | ``` 32 | 33 | ### Manual Installation 34 | 35 | #### Linux/macOS 36 | 37 | 1. Download the appropriate binary for your platform from the [Releases page](https://github.com/dagimg-dot/gitsnip/releases). 38 | 39 | 2. Extract the binary: 40 | ```bash 41 | tar -xzf gitsnip__.tar.gz 42 | ``` 43 | 44 | 3. Move the binary to a directory in your PATH: 45 | ```bash 46 | # Option 1: Move to user's local bin (recommended) 47 | mv gitsnip $HOME/.local/bin/ 48 | 49 | # Option 2: Move to system-wide bin (requires sudo) 50 | sudo mv gitsnip /usr/local/bin/ 51 | ``` 52 | 53 | 4. Verify installation by opening a new terminal: 54 | ```bash 55 | gitsnip version 56 | ``` 57 | 58 | > Note: For Option 1, make sure `$HOME/.local/bin` is in your PATH. Add `export PATH="$HOME/.local/bin:$PATH"` to your shell's config file (.bashrc, .zshrc, etc.) if needed. 59 | 60 | #### Windows 61 | 62 | 1. Download the Windows binary (`gitsnip_windows_amd64.zip`) from the [Releases page](https://github.com/dagimg-dot/gitsnip/releases). 63 | 64 | 2. Extract the ZIP file using File Explorer or PowerShell: 65 | ```powershell 66 | Expand-Archive -Path gitsnip_windows_amd64.zip -DestinationPath C:\Program Files\gitsnip 67 | ``` 68 | 69 | 3. Add to PATH (Choose one method): 70 | - **Using System Properties:** 71 | 1. Open System Properties (Win + R, type `sysdm.cpl`) 72 | 2. Go to "Advanced" tab → "Environment Variables" 73 | 3. Under "System variables", find and select "Path" 74 | 4. Click "Edit" → "New" 75 | 5. Add `C:\Program Files\gitsnip` 76 | 77 | - **Using PowerShell (requires admin):** 78 | ```powershell 79 | $oldPath = [Environment]::GetEnvironmentVariable('Path', 'Machine') 80 | $newPath = $oldPath + ';C:\Program Files\gitsnip' 81 | [Environment]::SetEnvironmentVariable('Path', $newPath, 'Machine') 82 | ``` 83 | 84 | 4. Verify installation by opening a new terminal: 85 | ```powershell 86 | gitsnip version 87 | ``` 88 | 89 | ## Usage 90 | 91 | Basic usage: 92 | 93 | ```bash 94 | gitsnip 95 | ``` 96 | 97 | ### Command Options 98 | 99 | ```bash 100 | Usage: 101 | gitsnip [output_dir] [flags] 102 | gitsnip [command] 103 | 104 | Available Commands: 105 | completion Generate the autocompletion script for the specified shell 106 | help Help about any command 107 | version Print the version information 108 | 109 | Flags: 110 | -b, --branch string Repository branch to download from (default "main") 111 | -h, --help help for gitsnip 112 | -m, --method string Download method ('api' or 'sparse') (default "sparse") 113 | -p, --provider string Repository provider ('github', more to come) 114 | -q, --quiet Suppress progress output during download 115 | -t, --token string GitHub API token for private repositories or increased rate limits 116 | ``` 117 | 118 | ### Examples 119 | 120 | 1. Download a specific folder from a public repository (default method is sparse checkout): 121 | 122 | ```bash 123 | gitsnip https://github.com/user/repo src/components ./my-components 124 | ``` 125 | 126 | 2. Download a specific folder from a public repository using the API method: 127 | 128 | ```bash 129 | gitsnip https://github.com/user/repo src/components ./my-components -m api 130 | ``` 131 | 132 | 3. Download from a specific branch: 133 | 134 | ```bash 135 | gitsnip https://github.com/user/repo docs ./docs -b develop 136 | ``` 137 | 138 | 4. Download from a private repository: 139 | 140 | ```bash 141 | gitsnip https://github.com/user/private-repo config ./config -t YOUR_GITHUB_TOKEN 142 | ``` 143 | 144 | ## Contributing 145 | 146 | Contributions are welcome! Please feel free to submit a Pull Request. 147 | 148 | ## License 149 | 150 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 151 | 152 | ## Troubleshooting 153 | 154 | ### Common Issues 155 | 156 | 1. **Rate Limit Exceeded**: When using the API method, you might hit GitHub's rate limits. Use a GitHub token to increase the limit or use the sparse checkout method. (See [Usage](#usage)) 157 | 2. **Permission Denied**: Make sure you have the correct permissions and token for private repositories. 158 | 159 | -------------------------------------------------------------------------------- /assets/gitsnip-showcase.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagimg-dot/gitsnip/83da535f46f865608175bb1fe6e0ebbca36c9309/assets/gitsnip-showcase.gif -------------------------------------------------------------------------------- /cmd/gitsnip/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/dagimg-dot/gitsnip/internal/cli" 8 | "github.com/dagimg-dot/gitsnip/internal/errors" 9 | ) 10 | 11 | func main() { 12 | if err := cli.Execute(); err != nil { 13 | fmt.Fprintf(os.Stderr, "Error: %s", errors.FormatError(err)) 14 | os.Exit(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dagimg-dot/gitsnip 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 7 | github.com/spf13/cobra v1.9.1 8 | github.com/spf13/pflag v1.0.6 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 3 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 4 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 5 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 6 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 7 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 8 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/dagimg-dot/gitsnip/internal/app/downloader" 5 | "github.com/dagimg-dot/gitsnip/internal/app/model" 6 | ) 7 | 8 | func Download(opts model.DownloadOptions) error { 9 | dl, err := downloader.GetDownloader(opts) 10 | if err != nil { 11 | return err 12 | } 13 | return dl.Download() 14 | } 15 | -------------------------------------------------------------------------------- /internal/app/downloader/factory.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "fmt" 5 | "github.com/dagimg-dot/gitsnip/internal/app/model" 6 | ) 7 | 8 | func GetDownloader(opts model.DownloadOptions) (Downloader, error) { 9 | switch opts.Method { 10 | case model.MethodTypeAPI: 11 | switch opts.Provider { 12 | case model.ProviderTypeGitHub: 13 | return NewGitHubAPIDownloader(opts), nil 14 | } 15 | case model.MethodTypeSparse: 16 | return NewSparseCheckoutDownloader(opts), nil 17 | } 18 | return nil, fmt.Errorf("unsupported provider/method") 19 | } 20 | -------------------------------------------------------------------------------- /internal/app/downloader/github_api.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/dagimg-dot/gitsnip/internal/app/model" 14 | "github.com/dagimg-dot/gitsnip/internal/errors" 15 | "github.com/dagimg-dot/gitsnip/internal/util" 16 | ) 17 | 18 | const ( 19 | GitHubAPIBaseURL = "https://api.github.com" 20 | ) 21 | 22 | type GitHubContentItem struct { 23 | Name string `json:"name"` 24 | Path string `json:"path"` 25 | Type string `json:"type"` 26 | DownloadURL string `json:"download_url"` 27 | URL string `json:"url"` 28 | } 29 | 30 | func NewGitHubAPIDownloader(opts model.DownloadOptions) Downloader { 31 | return &gitHubAPIDownloader{ 32 | opts: opts, 33 | client: util.NewHTTPClient(opts.Token), 34 | } 35 | } 36 | 37 | type gitHubAPIDownloader struct { 38 | opts model.DownloadOptions 39 | client *http.Client 40 | } 41 | 42 | func (g *gitHubAPIDownloader) Download() error { 43 | owner, repo, err := parseGitHubURL(g.opts.RepoURL) 44 | if err != nil { 45 | return &errors.AppError{ 46 | Err: errors.ErrInvalidURL, 47 | Message: "Invalid GitHub URL format", 48 | Hint: "URL should be in the format: https://github.com/owner/repo", 49 | } 50 | } 51 | 52 | if err := util.EnsureDir(g.opts.OutputDir); err != nil { 53 | return fmt.Errorf("failed to create output directory: %w", err) 54 | } 55 | 56 | if !g.opts.Quiet { 57 | fmt.Printf("Downloading directory %s from %s/%s (branch: %s)...\n", 58 | g.opts.Subdir, owner, repo, g.opts.Branch) 59 | } 60 | 61 | return g.downloadDirectory(owner, repo, g.opts.Subdir, g.opts.OutputDir) 62 | } 63 | 64 | func parseGitHubURL(repoURL string) (owner string, repo string, err error) { 65 | patterns := []*regexp.Regexp{ 66 | regexp.MustCompile(`github\.com[/:]([^/]+)/([^/]+?)(?:\.git)?$`), 67 | regexp.MustCompile(`github\.com[/:]([^/]+)/([^/]+?)(?:\.git)?$`), 68 | } 69 | 70 | for _, pattern := range patterns { 71 | matches := pattern.FindStringSubmatch(repoURL) 72 | if matches != nil && len(matches) >= 3 { 73 | return matches[1], matches[2], nil 74 | } 75 | } 76 | 77 | return "", "", fmt.Errorf("URL does not match GitHub repository pattern: %s", repoURL) 78 | } 79 | 80 | func (g *gitHubAPIDownloader) downloadDirectory(owner, repo, path, outputDir string) error { 81 | items, err := g.getContents(owner, repo, path) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | for _, item := range items { 87 | targetPath := filepath.Join(outputDir, item.Name) 88 | 89 | if item.Type == "dir" { 90 | if err := util.EnsureDir(targetPath); err != nil { 91 | return fmt.Errorf("failed to create directory %s: %w", targetPath, err) 92 | } 93 | 94 | if err := g.downloadDirectory(owner, repo, item.Path, targetPath); err != nil { 95 | return err 96 | } 97 | } else if item.Type == "file" { 98 | if !g.opts.Quiet { 99 | fmt.Printf("Downloading %s\n", item.Path) 100 | } 101 | if err := g.downloadFile(item.DownloadURL, targetPath); err != nil { 102 | return fmt.Errorf("failed to download file %s: %w", item.Path, err) 103 | } 104 | } 105 | } 106 | 107 | return nil 108 | } 109 | 110 | func (g *gitHubAPIDownloader) getContents(owner, repo, path string) ([]GitHubContentItem, error) { 111 | apiURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s", 112 | GitHubAPIBaseURL, owner, repo, url.PathEscape(path)) 113 | 114 | if g.opts.Branch != "" { 115 | apiURL = fmt.Sprintf("%s?ref=%s", apiURL, url.QueryEscape(g.opts.Branch)) 116 | } 117 | 118 | req, err := util.NewGitHubRequest("GET", apiURL, g.opts.Token) 119 | if err != nil { 120 | return nil, fmt.Errorf("failed to create request: %w", err) 121 | } 122 | 123 | resp, err := g.client.Do(req) 124 | if err != nil { 125 | return nil, &errors.AppError{ 126 | Err: errors.ErrNetworkFailure, 127 | Message: "Failed to connect to GitHub API", 128 | Hint: "Check your internet connection and try again", 129 | } 130 | } 131 | defer resp.Body.Close() 132 | 133 | if resp.StatusCode != http.StatusOK { 134 | body, _ := io.ReadAll(resp.Body) 135 | bodyStr := strings.TrimSpace(string(body)) 136 | return nil, errors.ParseGitHubAPIError(resp.StatusCode, bodyStr) 137 | } 138 | 139 | var items []GitHubContentItem 140 | if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { 141 | var item GitHubContentItem 142 | if errSingle := json.Unmarshal([]byte(err.Error()), &item); errSingle == nil { 143 | return []GitHubContentItem{item}, nil 144 | } 145 | return nil, fmt.Errorf("failed to parse API response: %w", err) 146 | } 147 | 148 | return items, nil 149 | } 150 | 151 | func (g *gitHubAPIDownloader) downloadFile(url, outputPath string) error { 152 | req, err := http.NewRequest("GET", url, nil) 153 | if err != nil { 154 | return fmt.Errorf("failed to create request: %w", err) 155 | } 156 | 157 | if g.opts.Token != "" { 158 | req.Header.Set("Authorization", "token "+g.opts.Token) 159 | } 160 | 161 | resp, err := g.client.Do(req) 162 | if err != nil { 163 | return &errors.AppError{ 164 | Err: errors.ErrNetworkFailure, 165 | Message: "Failed to download file", 166 | Hint: "Check your internet connection and try again", 167 | } 168 | } 169 | defer resp.Body.Close() 170 | 171 | if resp.StatusCode != http.StatusOK { 172 | body, _ := io.ReadAll(resp.Body) 173 | bodyStr := strings.TrimSpace(string(body)) 174 | return errors.ParseGitHubAPIError(resp.StatusCode, bodyStr) 175 | } 176 | 177 | return util.SaveToFile(outputPath, resp.Body) 178 | } 179 | -------------------------------------------------------------------------------- /internal/app/downloader/interface.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | type Downloader interface { 4 | Download() error 5 | } 6 | -------------------------------------------------------------------------------- /internal/app/downloader/sparse_checkout.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/dagimg-dot/gitsnip/internal/app/gitutil" 12 | "github.com/dagimg-dot/gitsnip/internal/app/model" 13 | "github.com/dagimg-dot/gitsnip/internal/errors" 14 | "github.com/dagimg-dot/gitsnip/internal/util" 15 | ) 16 | 17 | type sparseCheckoutDownloader struct { 18 | opts model.DownloadOptions 19 | } 20 | 21 | func NewSparseCheckoutDownloader(opts model.DownloadOptions) Downloader { 22 | return &sparseCheckoutDownloader{opts: opts} 23 | } 24 | 25 | func (s *sparseCheckoutDownloader) Download() error { 26 | if !gitutil.IsGitInstalled() { 27 | return &errors.AppError{ 28 | Err: errors.ErrGitNotInstalled, 29 | Message: "Git is not installed on this system", 30 | Hint: "Please install Git to use the sparse checkout method", 31 | } 32 | } 33 | 34 | if err := util.EnsureDir(s.opts.OutputDir); err != nil { 35 | return fmt.Errorf("failed to create output directory: %w", err) 36 | } 37 | 38 | if !s.opts.Quiet { 39 | if s.opts.Branch == "" { 40 | fmt.Printf("Downloading directory %s from %s (default branch) using sparse checkout...\n", 41 | s.opts.Subdir, s.opts.RepoURL) 42 | } else { 43 | fmt.Printf("Downloading directory %s from %s (branch: %s) using sparse checkout...\n", 44 | s.opts.Subdir, s.opts.RepoURL, s.opts.Branch) 45 | } 46 | } 47 | 48 | tempDir, err := gitutil.CreateTempDir() 49 | if err != nil { 50 | return err 51 | } 52 | defer gitutil.CleanupTempDir(tempDir) 53 | 54 | repoURL := s.getAuthenticatedRepoURL() 55 | 56 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) 57 | defer cancel() 58 | 59 | if err := s.initRepo(ctx, tempDir, repoURL); err != nil { 60 | return err 61 | } 62 | 63 | if err := s.setupSparseCheckout(ctx, tempDir); err != nil { 64 | return err 65 | } 66 | 67 | if err := s.pullContent(ctx, tempDir); err != nil { 68 | return err 69 | } 70 | 71 | sparsePath := filepath.Join(tempDir, s.opts.Subdir) 72 | if _, err := os.Stat(sparsePath); os.IsNotExist(err) { 73 | return &errors.AppError{ 74 | Err: errors.ErrPathNotFound, 75 | Message: fmt.Sprintf("Directory '%s' not found in the repository", s.opts.Subdir), 76 | Hint: "Check that the folder path exists in the repository", 77 | } 78 | } 79 | 80 | if !s.opts.Quiet { 81 | fmt.Printf("Copying files to %s...\n", s.opts.OutputDir) 82 | } 83 | 84 | if err := util.CopyDirectory(sparsePath, s.opts.OutputDir); err != nil { 85 | return fmt.Errorf("failed to copy directory: %w", err) 86 | } 87 | 88 | if !s.opts.Quiet { 89 | fmt.Println("Download completed successfully.") 90 | } 91 | return nil 92 | } 93 | 94 | func (s *sparseCheckoutDownloader) getAuthenticatedRepoURL() string { 95 | repoURL := s.opts.RepoURL 96 | 97 | if strings.HasPrefix(repoURL, "github.com/") { 98 | repoURL = "https://" + repoURL 99 | } 100 | 101 | if s.opts.Token == "" { 102 | return repoURL 103 | } 104 | 105 | if strings.HasPrefix(repoURL, "https://") { 106 | parts := strings.SplitN(repoURL[8:], "/", 2) 107 | if len(parts) == 2 { 108 | return fmt.Sprintf("https://%s@%s/%s", s.opts.Token, parts[0], parts[1]) 109 | } 110 | } 111 | 112 | return repoURL 113 | } 114 | 115 | func (s *sparseCheckoutDownloader) initRepo(ctx context.Context, dir, repoURL string) error { 116 | if _, err := gitutil.RunGitCommand(ctx, dir, "init"); err != nil { 117 | return errors.ParseGitError(err, "git init failed") 118 | } 119 | 120 | if _, err := gitutil.RunGitCommand(ctx, dir, "remote", "add", "origin", repoURL); err != nil { 121 | return errors.ParseGitError(err, "failed to add remote") 122 | } 123 | 124 | return nil 125 | } 126 | 127 | func (s *sparseCheckoutDownloader) setupSparseCheckout(ctx context.Context, dir string) error { 128 | if _, err := gitutil.RunGitCommand(ctx, dir, "sparse-checkout", "init", "--cone"); err != nil { 129 | return errors.ParseGitError(err, "failed to enable sparse checkout") 130 | } 131 | 132 | if _, err := gitutil.RunGitCommand(ctx, dir, "sparse-checkout", "set", s.opts.Subdir); err != nil { 133 | return errors.ParseGitError(err, "failed to set sparse checkout pattern") 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func (s *sparseCheckoutDownloader) pullContent(ctx context.Context, dir string) error { 140 | if !s.opts.Quiet { 141 | fmt.Println("Downloading content from repository...") 142 | } 143 | 144 | fetchArgs := []string{"fetch", "--depth=1", "--no-tags", "origin"} 145 | if s.opts.Branch != "" { 146 | fetchArgs = append(fetchArgs, s.opts.Branch) 147 | } 148 | if _, err := gitutil.RunGitCommand(ctx, dir, fetchArgs...); err != nil { 149 | return errors.ParseGitError(err, "failed to fetch content") 150 | } 151 | 152 | if _, err := gitutil.RunGitCommand(ctx, dir, "checkout", "FETCH_HEAD"); err != nil { 153 | return errors.ParseGitError(err, "failed to checkout content") 154 | } 155 | 156 | return nil 157 | } 158 | -------------------------------------------------------------------------------- /internal/app/gitutil/command.go: -------------------------------------------------------------------------------- 1 | package gitutil 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | const DefaultTimeout = 60 * time.Second 14 | 15 | func RunGitCommand(ctx context.Context, dir string, args ...string) (string, error) { 16 | if ctx == nil { 17 | var cancel context.CancelFunc 18 | ctx, cancel = context.WithTimeout(context.Background(), DefaultTimeout) 19 | defer cancel() 20 | } 21 | 22 | cmd := exec.CommandContext(ctx, "git", args...) 23 | cmd.Dir = dir 24 | 25 | var stdout, stderr bytes.Buffer 26 | cmd.Stdout = &stdout 27 | cmd.Stderr = &stderr 28 | 29 | err := cmd.Run() 30 | if err != nil { 31 | cmdStr := fmt.Sprintf("git %s", strings.Join(args, " ")) 32 | return "", fmt.Errorf("%s: %w (%s)", cmdStr, err, stderr.String()) 33 | } 34 | 35 | return stdout.String(), nil 36 | } 37 | 38 | func RunGitCommandWithInput(ctx context.Context, dir, input string, args ...string) (string, error) { 39 | if ctx == nil { 40 | var cancel context.CancelFunc 41 | ctx, cancel = context.WithTimeout(context.Background(), DefaultTimeout) 42 | defer cancel() 43 | } 44 | 45 | cmd := exec.CommandContext(ctx, "git", args...) 46 | cmd.Dir = dir 47 | 48 | var stdout, stderr bytes.Buffer 49 | cmd.Stdout = &stdout 50 | cmd.Stderr = &stderr 51 | cmd.Stdin = strings.NewReader(input) 52 | 53 | err := cmd.Run() 54 | if err != nil { 55 | cmdStr := fmt.Sprintf("git %s", strings.Join(args, " ")) 56 | return "", fmt.Errorf("%s: %w (%s)", cmdStr, err, stderr.String()) 57 | } 58 | 59 | return stdout.String(), nil 60 | } 61 | 62 | func IsGitInstalled() bool { 63 | _, err := exec.LookPath("git") 64 | return err == nil 65 | } 66 | 67 | func GitVersion() (string, error) { 68 | cmd := exec.Command("git", "--version") 69 | output, err := cmd.Output() 70 | if err != nil { 71 | return "", fmt.Errorf("failed to get git version: %w", err) 72 | } 73 | return strings.TrimSpace(string(output)), nil 74 | } 75 | 76 | func CreateTempDir() (string, error) { 77 | tempDir, err := os.MkdirTemp("", "gitsnip-*") 78 | if err != nil { 79 | return "", fmt.Errorf("failed to create temporary directory: %w", err) 80 | } 81 | return tempDir, nil 82 | } 83 | 84 | func CleanupTempDir(dir string) error { 85 | return os.RemoveAll(dir) 86 | } 87 | -------------------------------------------------------------------------------- /internal/app/model/types.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type MethodType string 4 | 5 | const ( 6 | MethodTypeSparse MethodType = "sparse" 7 | MethodTypeAPI MethodType = "api" 8 | ) 9 | 10 | type ProviderType string 11 | 12 | const ( 13 | ProviderTypeGitHub ProviderType = "github" 14 | ) 15 | 16 | type DownloadOptions struct { 17 | RepoURL string 18 | Subdir string 19 | OutputDir string 20 | Branch string 21 | Token string 22 | Method MethodType 23 | Provider ProviderType 24 | Quiet bool 25 | } 26 | -------------------------------------------------------------------------------- /internal/cli/root.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/dagimg-dot/gitsnip/internal/app" 10 | "github.com/dagimg-dot/gitsnip/internal/app/model" 11 | apperrors "github.com/dagimg-dot/gitsnip/internal/errors" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var ( 16 | branch string 17 | method string 18 | token string 19 | provider string 20 | quiet bool 21 | 22 | rootCmd = &cobra.Command{ 23 | Use: "gitsnip [output_dir]", 24 | Short: "Download a specific folder from a Git repository (GitHub)", 25 | Long: `Gitsnip allows you to download a specific folder from a remote Git 26 | repository without cloning the entire repository. 27 | 28 | Arguments: 29 | repository_url: URL of the GitHub repository (e.g., https://github.com/user/repo) 30 | folder_path: Path to the folder within the repository you want to download. 31 | output_dir: Optional. Directory where the folder should be saved. 32 | Defaults to the folder's base name in the current directory.`, 33 | 34 | PreRunE: func(cmd *cobra.Command, args []string) error { 35 | if len(args) == 0 { 36 | cmd.Help() 37 | return nil 38 | } 39 | return nil 40 | }, 41 | Args: cobra.RangeArgs(0, 3), 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | if len(args) == 0 { 44 | return nil 45 | } 46 | 47 | if len(args) < 2 { 48 | return fmt.Errorf("requires at least repository_url and folder_path arguments") 49 | } 50 | 51 | repoURL := args[0] 52 | folderPath := args[1] 53 | outputDir := "" // default 54 | 55 | if len(args) == 3 { 56 | outputDir = args[2] 57 | } else { 58 | outputDir = filepath.Base(folderPath) 59 | } 60 | 61 | if provider == "" { 62 | if strings.Contains(repoURL, "github.com") { 63 | provider = "github" 64 | } else { 65 | provider = "github" 66 | } 67 | } 68 | 69 | methodType := model.MethodTypeSparse 70 | if method == "api" { 71 | methodType = model.MethodTypeAPI 72 | } 73 | 74 | providerType := model.ProviderTypeGitHub 75 | // TODO: add other providers when supported 76 | 77 | opts := model.DownloadOptions{ 78 | RepoURL: repoURL, 79 | Subdir: folderPath, 80 | OutputDir: outputDir, 81 | Branch: branch, 82 | Token: token, 83 | Method: methodType, 84 | Provider: providerType, 85 | Quiet: quiet, 86 | } 87 | 88 | if !quiet { 89 | fmt.Printf("Repository URL: %s\n", repoURL) 90 | fmt.Printf("Folder Path: %s\n", folderPath) 91 | fmt.Printf("Target Branch: %s\n", branch) 92 | fmt.Printf("Download Method: %s\n", method) 93 | fmt.Printf("Output Dir: %s\n", outputDir) 94 | fmt.Printf("Provider: %s\n", provider) 95 | fmt.Println("--------------------------------") 96 | } 97 | 98 | err := app.Download(opts) 99 | 100 | var appErr *apperrors.AppError 101 | if errors.As(err, &appErr) { 102 | cmd.SilenceUsage = true 103 | } 104 | 105 | return err 106 | }, 107 | } 108 | ) 109 | 110 | // Execute adds all child commands to the root command and sets flags appropriately. 111 | // This is called by main.main(). 112 | func Execute() error { 113 | rootCmd.SilenceErrors = true 114 | rootCmd.SilenceUsage = false 115 | return rootCmd.Execute() 116 | } 117 | 118 | // init is called by Go before main() 119 | func init() { 120 | // TODO: use PersistentFlags if i want flags to be available to subcommands as well 121 | rootCmd.Flags().StringVarP(&branch, "branch", "b", "main", "Repository branch to download from") 122 | rootCmd.Flags().StringVarP(&method, "method", "m", "sparse", "Download method ('api' or 'sparse')") 123 | rootCmd.Flags().StringVarP(&token, "token", "t", "", "GitHub API token for private repositories or increased rate limits") 124 | rootCmd.Flags().StringVarP(&provider, "provider", "p", "", "Repository provider ('github', more to come)") 125 | rootCmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Suppress progress output during download") 126 | } 127 | -------------------------------------------------------------------------------- /internal/cli/version.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var ( 10 | version = "dev" 11 | commit = "none" 12 | buildDate = "unknown" 13 | builtBy = "unknown" 14 | 15 | versionCmd = &cobra.Command{ 16 | Use: "version", 17 | Short: "Print the version information", 18 | Long: `Display version, build, and other information about GitSnip.`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | fmt.Printf("GitSnip %s\n", version) 21 | fmt.Printf(" Commit: %s\n", commit) 22 | fmt.Printf(" Built on: %s\n", buildDate) 23 | fmt.Printf(" Built by: %s\n", builtBy) 24 | }, 25 | } 26 | ) 27 | 28 | func init() { 29 | rootCmd.AddCommand(versionCmd) 30 | } 31 | -------------------------------------------------------------------------------- /internal/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | ErrRateLimitExceeded = errors.New("GitHub API rate limit exceeded") 11 | ErrAuthenticationRequired = errors.New("authentication required for this repository") 12 | ErrRepositoryNotFound = errors.New("repository not found") 13 | ErrPathNotFound = errors.New("path not found in repository") 14 | ErrNetworkFailure = errors.New("network connection error") 15 | ErrInvalidURL = errors.New("invalid repository URL") 16 | ErrGitNotInstalled = errors.New("git is not installed") 17 | ErrGitCommandFailed = errors.New("git command failed") 18 | ErrGitCloneFailed = errors.New("git clone failed") 19 | ErrGitFetchFailed = errors.New("git fetch failed") 20 | ErrGitCheckoutFailed = errors.New("git checkout failed") 21 | ErrGitInvalidRepository = errors.New("invalid git repository") 22 | ) 23 | 24 | type AppError struct { 25 | Err error 26 | Message string 27 | Hint string 28 | StatusCode int 29 | } 30 | 31 | func (e *AppError) Error() string { 32 | return e.Message 33 | } 34 | 35 | func (e *AppError) Unwrap() error { 36 | return e.Err 37 | } 38 | 39 | func FormatError(err error) string { 40 | var appErr *AppError 41 | if errors.As(err, &appErr) { 42 | var builder strings.Builder 43 | builder.WriteString(fmt.Sprintf("%s\n", appErr.Message)) 44 | 45 | if appErr.Hint != "" { 46 | builder.WriteString(fmt.Sprintf("Hint: %s\n", appErr.Hint)) 47 | } 48 | 49 | return builder.String() 50 | } 51 | 52 | return fmt.Sprintf("%v\n", err) 53 | } 54 | 55 | func ParseGitHubAPIError(statusCode int, body string) error { 56 | loweredBody := strings.ToLower(body) 57 | 58 | var appErr AppError 59 | appErr.StatusCode = statusCode 60 | 61 | switch statusCode { 62 | case 401: 63 | appErr.Err = ErrAuthenticationRequired 64 | appErr.Message = "Authentication required to access this repository" 65 | appErr.Hint = "Use --token flag to provide a GitHub token with appropriate permissions" 66 | 67 | case 403: 68 | if strings.Contains(loweredBody, "rate limit exceeded") { 69 | appErr.Err = ErrRateLimitExceeded 70 | appErr.Message = "GitHub API rate limit exceeded" 71 | appErr.Hint = "Use --token flag to provide a GitHub token to increase rate limits" 72 | } else { 73 | appErr.Err = ErrAuthenticationRequired 74 | appErr.Message = "Access forbidden to this repository or resource" 75 | appErr.Hint = "Check that your token has the correct permissions" 76 | } 77 | 78 | case 404: 79 | if strings.Contains(loweredBody, "not found") { 80 | appErr.Err = ErrRepositoryNotFound 81 | appErr.Message = "Repository or path not found" 82 | appErr.Hint = "Check that the repository URL and path are correct" 83 | } else { 84 | appErr.Err = ErrPathNotFound 85 | appErr.Message = "Path not found in repository" 86 | appErr.Hint = "Check that the folder path exists in the specified branch" 87 | } 88 | 89 | default: 90 | appErr.Err = errors.New(body) 91 | appErr.Message = fmt.Sprintf("GitHub API error (%d): %s", statusCode, body) 92 | } 93 | 94 | return &appErr 95 | } 96 | 97 | func ParseGitError(err error, stderr string) error { 98 | loweredStderr := strings.ToLower(stderr) 99 | 100 | var appErr AppError 101 | appErr.Err = ErrGitCommandFailed 102 | 103 | switch { 104 | case strings.Contains(loweredStderr, "repository not found"): 105 | appErr.Err = ErrRepositoryNotFound 106 | appErr.Message = "Repository not found" 107 | appErr.Hint = "Check that the repository URL is correct" 108 | 109 | case strings.Contains(loweredStderr, "could not find remote branch") || 110 | strings.Contains(loweredStderr, "pathspec") && strings.Contains(loweredStderr, "did not match"): 111 | appErr.Err = ErrPathNotFound 112 | appErr.Message = "Branch or reference not found" 113 | appErr.Hint = "Check that the branch name or reference exists in the repository" 114 | 115 | case strings.Contains(loweredStderr, "authentication failed") || 116 | strings.Contains(loweredStderr, "authorization failed") || 117 | strings.Contains(loweredStderr, "could not read from remote repository"): 118 | appErr.Err = ErrAuthenticationRequired 119 | appErr.Message = "Authentication required to access this repository" 120 | appErr.Hint = "Use --token flag to provide a GitHub token with appropriate permissions" 121 | 122 | case strings.Contains(loweredStderr, "failed to connect") || 123 | strings.Contains(loweredStderr, "could not resolve host"): 124 | appErr.Err = ErrNetworkFailure 125 | appErr.Message = "Failed to connect to remote repository" 126 | appErr.Hint = "Check your internet connection and try again" 127 | 128 | default: 129 | appErr.Err = err 130 | appErr.Message = fmt.Sprintf("Git operation failed: %v", err) 131 | if stderr != "" { 132 | appErr.Hint = fmt.Sprintf("Git error output: %s", stderr) 133 | } 134 | } 135 | 136 | return &appErr 137 | } 138 | -------------------------------------------------------------------------------- /internal/util/fs.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | func EnsureDir(path string) error { 11 | return os.MkdirAll(path, 0755) 12 | } 13 | 14 | func FileExists(path string) bool { 15 | info, err := os.Stat(path) 16 | if os.IsNotExist(err) { 17 | return false 18 | } 19 | return !info.IsDir() 20 | } 21 | 22 | func SaveToFile(path string, content io.Reader) error { 23 | dir := filepath.Dir(path) 24 | if err := EnsureDir(dir); err != nil { 25 | return fmt.Errorf("failed to create directory %s: %w", dir, err) 26 | } 27 | 28 | file, err := os.Create(path) 29 | if err != nil { 30 | return fmt.Errorf("failed to create file %s: %w", path, err) 31 | } 32 | defer file.Close() 33 | 34 | _, err = io.Copy(file, content) 35 | if err != nil { 36 | return fmt.Errorf("failed to write to file %s: %w", path, err) 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func CopyDirectory(src, dst string) error { 43 | srcInfo, err := os.Stat(src) 44 | if err != nil { 45 | return fmt.Errorf("failed to stat source directory: %w", err) 46 | } 47 | 48 | if err := EnsureDir(dst); err != nil { 49 | return fmt.Errorf("failed to create destination directory: %w", err) 50 | } 51 | 52 | if err := os.Chmod(dst, srcInfo.Mode()); err != nil { 53 | return fmt.Errorf("failed to set permissions on destination directory: %w", err) 54 | } 55 | 56 | entries, err := os.ReadDir(src) 57 | if err != nil { 58 | return fmt.Errorf("failed to read source directory: %w", err) 59 | } 60 | 61 | for _, entry := range entries { 62 | srcPath := filepath.Join(src, entry.Name()) 63 | dstPath := filepath.Join(dst, entry.Name()) 64 | 65 | if entry.IsDir() { 66 | if err := CopyDirectory(srcPath, dstPath); err != nil { 67 | return err 68 | } 69 | } else { 70 | if err := CopyFile(srcPath, dstPath); err != nil { 71 | return err 72 | } 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func CopyFile(src, dst string) error { 80 | srcFile, err := os.Open(src) 81 | if err != nil { 82 | return fmt.Errorf("failed to open source file: %w", err) 83 | } 84 | defer srcFile.Close() 85 | 86 | srcInfo, err := srcFile.Stat() 87 | if err != nil { 88 | return fmt.Errorf("failed to stat source file: %w", err) 89 | } 90 | 91 | dstDir := filepath.Dir(dst) 92 | if err := EnsureDir(dstDir); err != nil { 93 | return fmt.Errorf("failed to create destination directory: %w", err) 94 | } 95 | 96 | dstFile, err := os.Create(dst) 97 | if err != nil { 98 | return fmt.Errorf("failed to create destination file: %w", err) 99 | } 100 | defer dstFile.Close() 101 | 102 | if _, err = io.Copy(dstFile, srcFile); err != nil { 103 | return fmt.Errorf("failed to copy file content: %w", err) 104 | } 105 | 106 | if err := os.Chmod(dst, srcInfo.Mode()); err != nil { 107 | return fmt.Errorf("failed to set permissions on destination file: %w", err) 108 | } 109 | 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /internal/util/http.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | const ( 9 | UserAgent = "GitSnip/1.0" 10 | DefaultTimeout = 30 * time.Second 11 | ) 12 | 13 | func NewHTTPClient(token string) *http.Client { 14 | client := &http.Client{ 15 | Timeout: DefaultTimeout, 16 | } 17 | 18 | return client 19 | } 20 | 21 | func NewGitHubRequest(method, url string, token string) (*http.Request, error) { 22 | req, err := http.NewRequest(method, url, nil) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | req.Header.Set("User-Agent", UserAgent) 28 | req.Header.Set("Accept", "application/vnd.github.v3+json") 29 | 30 | if token != "" { 31 | req.Header.Set("Authorization", "token "+token) 32 | } 33 | 34 | return req, nil 35 | } 36 | --------------------------------------------------------------------------------