Skip to content

Commit 0a69584

Browse files
committed
feat: sparse checkout method
1 parent e929aec commit 0a69584

File tree

5 files changed

+383
-18
lines changed

5 files changed

+383
-18
lines changed
Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,5 @@
11
package downloader
22

3-
import (
4-
"fmt"
5-
6-
"github.com/dagimg-dot/gitsnip/internal/app/model"
7-
)
8-
93
type Downloader interface {
104
Download() error
115
}
12-
13-
func NewSparseCheckoutDownloader(opts model.DownloadOptions) Downloader {
14-
return &sparseCheckoutDownloader{opts: opts}
15-
}
16-
17-
type sparseCheckoutDownloader struct {
18-
opts model.DownloadOptions
19-
}
20-
21-
func (s *sparseCheckoutDownloader) Download() error {
22-
return fmt.Errorf("sparse-checkout method not implemented yet")
23-
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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 !s.opts.Quiet {
60+
fmt.Println("Setting up Git repository...")
61+
}
62+
if err := s.initRepo(ctx, tempDir, repoURL); err != nil {
63+
return err
64+
}
65+
66+
if err := s.setupSparseCheckout(ctx, tempDir); err != nil {
67+
return err
68+
}
69+
70+
if err := s.pullContent(ctx, tempDir); err != nil {
71+
return err
72+
}
73+
74+
sparsePath := filepath.Join(tempDir, s.opts.Subdir)
75+
if _, err := os.Stat(sparsePath); os.IsNotExist(err) {
76+
return &errors.AppError{
77+
Err: errors.ErrPathNotFound,
78+
Message: fmt.Sprintf("Directory '%s' not found in the repository", s.opts.Subdir),
79+
Hint: "Check that the folder path exists in the repository",
80+
}
81+
}
82+
83+
if !s.opts.Quiet {
84+
fmt.Printf("Copying files to %s...\n", s.opts.OutputDir)
85+
}
86+
87+
if err := util.CopyDirectory(sparsePath, s.opts.OutputDir); err != nil {
88+
return fmt.Errorf("failed to copy directory: %w", err)
89+
}
90+
91+
if !s.opts.Quiet {
92+
fmt.Println("Download completed successfully.")
93+
}
94+
return nil
95+
}
96+
97+
func (s *sparseCheckoutDownloader) getAuthenticatedRepoURL() string {
98+
if s.opts.Token == "" {
99+
return s.opts.RepoURL
100+
}
101+
102+
if strings.HasPrefix(s.opts.RepoURL, "https://") {
103+
parts := strings.SplitN(s.opts.RepoURL[8:], "/", 2)
104+
if len(parts) == 2 {
105+
return fmt.Sprintf("https://%s@%s/%s", s.opts.Token, parts[0], parts[1])
106+
}
107+
}
108+
109+
return s.opts.RepoURL
110+
}
111+
112+
func (s *sparseCheckoutDownloader) initRepo(ctx context.Context, dir, repoURL string) error {
113+
if _, err := gitutil.RunGitCommand(ctx, dir, "init"); err != nil {
114+
return errors.ParseGitError(err, "git init failed")
115+
}
116+
117+
if _, err := gitutil.RunGitCommand(ctx, dir, "remote", "add", "origin", repoURL); err != nil {
118+
return errors.ParseGitError(err, "failed to add remote")
119+
}
120+
121+
return nil
122+
}
123+
124+
func (s *sparseCheckoutDownloader) setupSparseCheckout(ctx context.Context, dir string) error {
125+
if _, err := gitutil.RunGitCommand(ctx, dir, "config", "core.sparseCheckout", "true"); err != nil {
126+
return errors.ParseGitError(err, "failed to enable sparse checkout")
127+
}
128+
129+
err := s.setupModernSparseCheckout(ctx, dir)
130+
if err != nil {
131+
return s.setupLegacySparseCheckout(ctx, dir)
132+
}
133+
134+
return nil
135+
}
136+
137+
func (s *sparseCheckoutDownloader) setupModernSparseCheckout(ctx context.Context, dir string) error {
138+
_, err := gitutil.RunGitCommand(ctx, dir, "sparse-checkout", "set", s.opts.Subdir)
139+
if err != nil {
140+
return err
141+
}
142+
return nil
143+
}
144+
145+
func (s *sparseCheckoutDownloader) setupLegacySparseCheckout(_ context.Context, dir string) error {
146+
sparseCheckoutPath := filepath.Join(dir, ".git", "info", "sparse-checkout")
147+
sparseCheckoutDir := filepath.Dir(sparseCheckoutPath)
148+
149+
if err := os.MkdirAll(sparseCheckoutDir, 0755); err != nil {
150+
return fmt.Errorf("failed to create sparse checkout directory: %w", err)
151+
}
152+
153+
sparseCheckoutPattern := fmt.Sprintf("%s/**", s.opts.Subdir)
154+
if err := os.WriteFile(sparseCheckoutPath, []byte(sparseCheckoutPattern), 0644); err != nil {
155+
return fmt.Errorf("failed to write sparse checkout file: %w", err)
156+
}
157+
158+
return nil
159+
}
160+
161+
func (s *sparseCheckoutDownloader) pullContent(ctx context.Context, dir string) error {
162+
args := []string{"pull", "--depth=1", "origin"}
163+
164+
if s.opts.Branch != "" {
165+
args = append(args, s.opts.Branch)
166+
}
167+
168+
if !s.opts.Quiet {
169+
fmt.Println("Downloading content from repository...")
170+
}
171+
172+
_, err := gitutil.RunGitCommand(ctx, dir, args...)
173+
if err != nil {
174+
return errors.ParseGitError(err, "failed to pull content")
175+
}
176+
177+
return nil
178+
}

internal/app/gitutil/command.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
}

internal/errors/errors.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ var (
1313
ErrPathNotFound = errors.New("path not found in repository")
1414
ErrNetworkFailure = errors.New("network connection error")
1515
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")
1622
)
1723

1824
type AppError struct {
@@ -87,3 +93,45 @@ func ParseGitHubAPIError(statusCode int, body string) error {
8793

8894
return &appErr
8995
}
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+
}

0 commit comments

Comments
 (0)