Compare commits

..

5 commits

Author SHA1 Message Date
Snider
a77024aad4 feat(collect): add local directory collection
Add `borg collect local` command to collect files from the local
filesystem into a DataNode.

Features:
- Walks directory tree (defaults to CWD)
- Respects .gitignore patterns by default
- Excludes hidden files by default (--hidden to include)
- Custom exclude patterns via --exclude flag
- Output formats: datanode, tim, trix, stim
- Compression: none, gz, xz

Examples:
  borg collect local
  borg collect local ./src --output src.tar.xz --compression xz
  borg collect local . --format stim --password secret

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 07:12:10 +00:00
Snider
eae9de0cf6
Merge pull request #18 from Snider/dependabot/go_modules/golang.org/x/crypto-0.45.0
Bump golang.org/x/crypto from 0.44.0 to 0.45.0
2026-02-02 06:43:32 +00:00
Snider
6e38c4f3a6
Merge pull request #112 from Snider/copilot/combine-prs-into-one-update
[WIP] Combine multiple PRs into a single squash commit
2026-02-02 06:35:39 +00:00
copilot-swe-agent[bot]
c26d841b1b Initial plan 2026-02-02 05:36:04 +00:00
dependabot[bot]
b94ffbab5e
Bump golang.org/x/crypto from 0.44.0 to 0.45.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.44.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-20 02:44:02 +00:00
23 changed files with 415 additions and 183 deletions

View file

@ -11,7 +11,6 @@ import (
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/github"
"github.com/Snider/Borg/pkg/httpclient"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/ui"
@ -43,15 +42,7 @@ func NewAllCmd() *cobra.Command {
return err
}
totalTimeout, _ := cmd.Flags().GetDuration("timeout")
connectTimeout, _ := cmd.Flags().GetDuration("connect-timeout")
tlsTimeout, _ := cmd.Flags().GetDuration("tls-timeout")
headerTimeout, _ := cmd.Flags().GetDuration("header-timeout")
httpClient := httpclient.NewClient(totalTimeout, connectTimeout, tlsTimeout, headerTimeout)
githubClient := github.NewGithubClient(httpClient)
repos, err := githubClient.GetPublicRepos(cmd.Context(), owner)
repos, err := GithubClient.GetPublicRepos(cmd.Context(), owner)
if err != nil {
return err
}

View file

@ -23,7 +23,7 @@ func TestAllCmd_Good(t *testing.T) {
},
})
oldNewAuthenticatedClient := github.NewAuthenticatedClient
github.NewAuthenticatedClient = func(ctx context.Context, baseClient *http.Client) *http.Client {
github.NewAuthenticatedClient = func(ctx context.Context) *http.Client {
return mockGithubClient
}
defer func() {
@ -67,7 +67,7 @@ func TestAllCmd_Bad(t *testing.T) {
},
})
oldNewAuthenticatedClient := github.NewAuthenticatedClient
github.NewAuthenticatedClient = func(ctx context.Context, baseClient *http.Client) *http.Client {
github.NewAuthenticatedClient = func(ctx context.Context) *http.Client {
return mockGithubClient
}
defer func() {
@ -96,7 +96,7 @@ func TestAllCmd_Ugly(t *testing.T) {
},
})
oldNewAuthenticatedClient := github.NewAuthenticatedClient
github.NewAuthenticatedClient = func(ctx context.Context, baseClient *http.Client) *http.Client {
github.NewAuthenticatedClient = func(ctx context.Context) *http.Client {
return mockGithubClient
}
defer func() {

View file

@ -1,8 +1,6 @@
package cmd
import (
"time"
"github.com/spf13/cobra"
)
@ -13,18 +11,11 @@ func init() {
RootCmd.AddCommand(GetCollectCmd())
}
func NewCollectCmd() *cobra.Command {
cmd := &cobra.Command{
return &cobra.Command{
Use: "collect",
Short: "Collect a resource from a URI.",
Long: `Collect a resource from a URI and store it in a DataNode.`,
}
cmd.PersistentFlags().Duration("timeout", 0, "Total request timeout (e.g., 60s). 0 means no timeout.")
cmd.PersistentFlags().Duration("connect-timeout", 10*time.Second, "TCP connection establishment timeout (e.g., 10s)")
cmd.PersistentFlags().Duration("tls-timeout", 10*time.Second, "TLS handshake timeout (e.g., 10s)")
cmd.PersistentFlags().Duration("header-timeout", 30*time.Second, "Time to receive response headers timeout (e.g., 30s)")
return cmd
}
func GetCollectCmd() *cobra.Command {

View file

@ -6,7 +6,6 @@ import (
"os"
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/httpclient"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/ui"
@ -45,13 +44,6 @@ func NewCollectGithubRepoCmd() *cobra.Command {
return fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
}
totalTimeout, _ := cmd.Flags().GetDuration("timeout")
connectTimeout, _ := cmd.Flags().GetDuration("connect-timeout")
tlsTimeout, _ := cmd.Flags().GetDuration("tls-timeout")
headerTimeout, _ := cmd.Flags().GetDuration("header-timeout")
_ = httpclient.NewClient(totalTimeout, connectTimeout, tlsTimeout, headerTimeout)
prompter := ui.NewNonInteractivePrompter(ui.GetVCSQuote)
prompter.Start()
defer prompter.Stop()

View file

@ -4,24 +4,20 @@ import (
"fmt"
"github.com/Snider/Borg/pkg/github"
"github.com/Snider/Borg/pkg/httpclient"
"github.com/spf13/cobra"
)
var (
// GithubClient is the github client used by the command. It can be replaced for testing.
GithubClient = github.NewGithubClient()
)
var collectGithubReposCmd = &cobra.Command{
Use: "repos [user-or-org]",
Short: "Collects all public repositories for a user or organization",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
totalTimeout, _ := cmd.Flags().GetDuration("timeout")
connectTimeout, _ := cmd.Flags().GetDuration("connect-timeout")
tlsTimeout, _ := cmd.Flags().GetDuration("tls-timeout")
headerTimeout, _ := cmd.Flags().GetDuration("header-timeout")
httpClient := httpclient.NewClient(totalTimeout, connectTimeout, tlsTimeout, headerTimeout)
githubClient := github.NewGithubClient(httpClient)
repos, err := githubClient.GetPublicRepos(cmd.Context(), args[0])
repos, err := GithubClient.GetPublicRepos(cmd.Context(), args[0])
if err != nil {
return err
}

333
cmd/collect_local.go Normal file
View file

@ -0,0 +1,333 @@
package cmd
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/ui"
"github.com/spf13/cobra"
)
type CollectLocalCmd struct {
cobra.Command
}
// NewCollectLocalCmd creates a new collect local command
func NewCollectLocalCmd() *CollectLocalCmd {
c := &CollectLocalCmd{}
c.Command = cobra.Command{
Use: "local [directory]",
Short: "Collect files from a local directory",
Long: `Collect files from a local directory and store them in a DataNode.
If no directory is specified, the current working directory is used.
Examples:
borg collect local
borg collect local ./src
borg collect local /path/to/project --output project.tar
borg collect local . --format stim --password secret
borg collect local . --exclude "*.log" --exclude "node_modules"`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
directory := "."
if len(args) > 0 {
directory = args[0]
}
outputFile, _ := cmd.Flags().GetString("output")
format, _ := cmd.Flags().GetString("format")
compression, _ := cmd.Flags().GetString("compression")
password, _ := cmd.Flags().GetString("password")
excludes, _ := cmd.Flags().GetStringSlice("exclude")
includeHidden, _ := cmd.Flags().GetBool("hidden")
respectGitignore, _ := cmd.Flags().GetBool("gitignore")
finalPath, err := CollectLocal(directory, outputFile, format, compression, password, excludes, includeHidden, respectGitignore)
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "Files saved to", finalPath)
return nil
},
}
c.Flags().String("output", "", "Output file for the DataNode")
c.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
c.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
c.Flags().String("password", "", "Password for encryption (required for stim/trix format)")
c.Flags().StringSlice("exclude", nil, "Patterns to exclude (can be specified multiple times)")
c.Flags().Bool("hidden", false, "Include hidden files and directories")
c.Flags().Bool("gitignore", true, "Respect .gitignore files (default: true)")
return c
}
func init() {
collectCmd.AddCommand(&NewCollectLocalCmd().Command)
}
// CollectLocal collects files from a local directory into a DataNode
func CollectLocal(directory string, outputFile string, format string, compression string, password string, excludes []string, includeHidden bool, respectGitignore bool) (string, error) {
// Validate format
if format != "datanode" && format != "tim" && format != "trix" && format != "stim" {
return "", fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', 'trix', or 'stim')", format)
}
if (format == "stim" || format == "trix") && password == "" {
return "", fmt.Errorf("password is required for %s format", format)
}
if compression != "none" && compression != "gz" && compression != "xz" {
return "", fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
}
// Resolve directory path
absDir, err := filepath.Abs(directory)
if err != nil {
return "", fmt.Errorf("error resolving directory path: %w", err)
}
info, err := os.Stat(absDir)
if err != nil {
return "", fmt.Errorf("error accessing directory: %w", err)
}
if !info.IsDir() {
return "", fmt.Errorf("not a directory: %s", absDir)
}
// Load gitignore patterns if enabled
var gitignorePatterns []string
if respectGitignore {
gitignorePatterns = loadGitignore(absDir)
}
// Create DataNode and collect files
dn := datanode.New()
var fileCount int
bar := ui.NewProgressBar(-1, "Scanning files")
defer bar.Finish()
err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Get relative path
relPath, err := filepath.Rel(absDir, path)
if err != nil {
return err
}
// Skip root
if relPath == "." {
return nil
}
// Skip hidden files/dirs unless explicitly included
if !includeHidden && isHidden(relPath) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Check gitignore patterns
if respectGitignore && matchesGitignore(relPath, d.IsDir(), gitignorePatterns) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Check exclude patterns
if matchesExclude(relPath, excludes) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Skip directories (they're implicit in DataNode)
if d.IsDir() {
return nil
}
// Read file content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading %s: %w", relPath, err)
}
// Add to DataNode with forward slashes (tar convention)
dn.AddData(filepath.ToSlash(relPath), content)
fileCount++
bar.Describe(fmt.Sprintf("Collected %d files", fileCount))
return nil
})
if err != nil {
return "", fmt.Errorf("error walking directory: %w", err)
}
if fileCount == 0 {
return "", fmt.Errorf("no files found in %s", directory)
}
bar.Describe(fmt.Sprintf("Packaging %d files", fileCount))
// Convert to output format
var data []byte
if format == "tim" {
t, err := tim.FromDataNode(dn)
if err != nil {
return "", fmt.Errorf("error creating tim: %w", err)
}
data, err = t.ToTar()
if err != nil {
return "", fmt.Errorf("error serializing tim: %w", err)
}
} else if format == "stim" {
t, err := tim.FromDataNode(dn)
if err != nil {
return "", fmt.Errorf("error creating tim: %w", err)
}
data, err = t.ToSigil(password)
if err != nil {
return "", fmt.Errorf("error encrypting stim: %w", err)
}
} else if format == "trix" {
data, err = trix.ToTrix(dn, password)
if err != nil {
return "", fmt.Errorf("error serializing trix: %w", err)
}
} else {
data, err = dn.ToTar()
if err != nil {
return "", fmt.Errorf("error serializing DataNode: %w", err)
}
}
// Apply compression
compressedData, err := compress.Compress(data, compression)
if err != nil {
return "", fmt.Errorf("error compressing data: %w", err)
}
// Determine output filename
if outputFile == "" {
baseName := filepath.Base(absDir)
if baseName == "." || baseName == "/" {
baseName = "local"
}
outputFile = baseName + "." + format
if compression != "none" {
outputFile += "." + compression
}
}
err = os.WriteFile(outputFile, compressedData, 0644)
if err != nil {
return "", fmt.Errorf("error writing output file: %w", err)
}
return outputFile, nil
}
// isHidden checks if a path component starts with a dot
func isHidden(path string) bool {
parts := strings.Split(filepath.ToSlash(path), "/")
for _, part := range parts {
if strings.HasPrefix(part, ".") {
return true
}
}
return false
}
// loadGitignore loads patterns from .gitignore if it exists
func loadGitignore(dir string) []string {
var patterns []string
gitignorePath := filepath.Join(dir, ".gitignore")
content, err := os.ReadFile(gitignorePath)
if err != nil {
return patterns
}
lines := strings.Split(string(content), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
patterns = append(patterns, line)
}
return patterns
}
// matchesGitignore checks if a path matches any gitignore pattern
func matchesGitignore(path string, isDir bool, patterns []string) bool {
for _, pattern := range patterns {
// Handle directory-only patterns
if strings.HasSuffix(pattern, "/") {
if !isDir {
continue
}
pattern = strings.TrimSuffix(pattern, "/")
}
// Handle negation (simplified - just skip negated patterns)
if strings.HasPrefix(pattern, "!") {
continue
}
// Match against path components
matched, _ := filepath.Match(pattern, filepath.Base(path))
if matched {
return true
}
// Also try matching the full path
matched, _ = filepath.Match(pattern, path)
if matched {
return true
}
// Handle ** patterns (simplified)
if strings.Contains(pattern, "**") {
simplePattern := strings.ReplaceAll(pattern, "**", "*")
matched, _ = filepath.Match(simplePattern, path)
if matched {
return true
}
}
}
return false
}
// matchesExclude checks if a path matches any exclude pattern
func matchesExclude(path string, excludes []string) bool {
for _, pattern := range excludes {
// Match against basename
matched, _ := filepath.Match(pattern, filepath.Base(path))
if matched {
return true
}
// Match against full path
matched, _ = filepath.Match(pattern, path)
if matched {
return true
}
}
return false
}

View file

@ -5,7 +5,6 @@ import (
"os"
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/httpclient"
"github.com/Snider/Borg/pkg/pwa"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
@ -14,9 +13,17 @@ import (
"github.com/spf13/cobra"
)
type CollectPWACmd struct {
cobra.Command
PWAClient pwa.PWAClient
}
// NewCollectPWACmd creates a new collect pwa command
func NewCollectPWACmd() *cobra.Command {
cmd := &cobra.Command{
func NewCollectPWACmd() *CollectPWACmd {
c := &CollectPWACmd{
PWAClient: pwa.NewPWAClient(),
}
c.Command = cobra.Command{
Use: "pwa [url]",
Short: "Collect a single PWA using a URI",
Long: `Collect a single PWA and store it in a DataNode.
@ -37,15 +44,7 @@ Examples:
compression, _ := cmd.Flags().GetString("compression")
password, _ := cmd.Flags().GetString("password")
totalTimeout, _ := cmd.Flags().GetDuration("timeout")
connectTimeout, _ := cmd.Flags().GetDuration("connect-timeout")
tlsTimeout, _ := cmd.Flags().GetDuration("tls-timeout")
headerTimeout, _ := cmd.Flags().GetDuration("header-timeout")
httpClient := httpclient.NewClient(totalTimeout, connectTimeout, tlsTimeout, headerTimeout)
pwaClient := pwa.NewPWAClient(httpClient)
finalPath, err := CollectPWA(pwaClient, pwaURL, outputFile, format, compression, password)
finalPath, err := CollectPWA(c.PWAClient, pwaURL, outputFile, format, compression, password)
if err != nil {
return err
}
@ -53,16 +52,16 @@ Examples:
return nil
},
}
cmd.Flags().String("uri", "", "The URI of the PWA to collect (can also be passed as positional arg)")
cmd.Flags().String("output", "", "Output file for the DataNode")
cmd.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
cmd.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
cmd.Flags().String("password", "", "Password for encryption (required for stim format)")
return cmd
c.Flags().String("uri", "", "The URI of the PWA to collect (can also be passed as positional arg)")
c.Flags().String("output", "", "Output file for the DataNode")
c.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
c.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
c.Flags().String("password", "", "Password for encryption (required for stim format)")
return c
}
func init() {
collectCmd.AddCommand(NewCollectPWACmd())
collectCmd.AddCommand(&NewCollectPWACmd().Command)
}
func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format string, compression string, password string) (string, error) {
if pwaURL == "" {

View file

@ -6,7 +6,6 @@ import (
"github.com/schollz/progressbar/v3"
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/httpclient"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/ui"
@ -52,14 +51,7 @@ func NewCollectWebsiteCmd() *cobra.Command {
bar = ui.NewProgressBar(-1, "Crawling website")
}
totalTimeout, _ := cmd.Flags().GetDuration("timeout")
connectTimeout, _ := cmd.Flags().GetDuration("connect-timeout")
tlsTimeout, _ := cmd.Flags().GetDuration("tls-timeout")
headerTimeout, _ := cmd.Flags().GetDuration("header-timeout")
client := httpclient.NewClient(totalTimeout, connectTimeout, tlsTimeout, headerTimeout)
dn, err := website.DownloadAndPackageWebsite(websiteURL, depth, bar, client)
dn, err := website.DownloadAndPackageWebsite(websiteURL, depth, bar)
if err != nil {
return fmt.Errorf("error downloading and packaging website: %w", err)
}

View file

@ -2,7 +2,6 @@ package cmd
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
@ -15,7 +14,7 @@ import (
func TestCollectWebsiteCmd_Good(t *testing.T) {
// Mock the website downloader
oldDownloadAndPackageWebsite := website.DownloadAndPackageWebsite
website.DownloadAndPackageWebsite = func(startURL string, maxDepth int, bar *progressbar.ProgressBar, client *http.Client) (*datanode.DataNode, error) {
website.DownloadAndPackageWebsite = func(startURL string, maxDepth int, bar *progressbar.ProgressBar) (*datanode.DataNode, error) {
return datanode.New(), nil
}
defer func() {
@ -36,7 +35,7 @@ func TestCollectWebsiteCmd_Good(t *testing.T) {
func TestCollectWebsiteCmd_Bad(t *testing.T) {
// Mock the website downloader to return an error
oldDownloadAndPackageWebsite := website.DownloadAndPackageWebsite
website.DownloadAndPackageWebsite = func(startURL string, maxDepth int, bar *progressbar.ProgressBar, client *http.Client) (*datanode.DataNode, error) {
website.DownloadAndPackageWebsite = func(startURL string, maxDepth int, bar *progressbar.ProgressBar) (*datanode.DataNode, error) {
return nil, fmt.Errorf("website error")
}
defer func() {

View file

@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log"
"net/http"
"os"
"github.com/Snider/Borg/pkg/github"
@ -14,7 +13,7 @@ import (
func main() {
log.Println("Collecting all repositories for a user...")
repos, err := github.NewGithubClient(http.DefaultClient).GetPublicRepos(context.Background(), "Snider")
repos, err := github.NewGithubClient().GetPublicRepos(context.Background(), "Snider")
if err != nil {
log.Fatalf("Failed to get public repos: %v", err)
}

View file

@ -2,7 +2,6 @@ package main
import (
"log"
"net/http"
"os"
"github.com/Snider/Borg/pkg/pwa"
@ -11,7 +10,7 @@ import (
func main() {
log.Println("Collecting PWA...")
client := pwa.NewPWAClient(http.DefaultClient)
client := pwa.NewPWAClient()
pwaURL := "https://squoosh.app"
manifestURL, err := client.FindManifest(pwaURL)

View file

@ -2,7 +2,6 @@ package main
import (
"log"
"net/http"
"os"
"github.com/Snider/Borg/pkg/website"
@ -12,7 +11,7 @@ func main() {
log.Println("Collecting website...")
// Download and package the website.
dn, err := website.DownloadAndPackageWebsite("https://example.com", 2, nil, http.DefaultClient)
dn, err := website.DownloadAndPackageWebsite("https://example.com", 2, nil)
if err != nil {
log.Fatalf("Failed to collect website: %v", err)
}

Binary file not shown.

2
go.mod
View file

@ -60,7 +60,7 @@ require (
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.44.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect

4
go.sum
View file

@ -155,8 +155,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=

View file

@ -21,29 +21,22 @@ type GithubClient interface {
}
// NewGithubClient creates a new GithubClient.
func NewGithubClient(client *http.Client) GithubClient {
return &githubClient{client: client}
func NewGithubClient() GithubClient {
return &githubClient{}
}
type githubClient struct {
client *http.Client
}
type githubClient struct{}
// NewAuthenticatedClient creates a new authenticated http client.
var NewAuthenticatedClient = func(ctx context.Context, baseClient *http.Client) *http.Client {
var NewAuthenticatedClient = func(ctx context.Context) *http.Client {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
return baseClient
return http.DefaultClient
}
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
authedClient := oauth2.NewClient(ctx, ts)
authedClient.Transport = &oauth2.Transport{
Base: baseClient.Transport,
Source: ts,
}
return authedClient
return oauth2.NewClient(ctx, ts)
}
func (g *githubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
@ -51,7 +44,7 @@ func (g *githubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]
}
func (g *githubClient) getPublicReposWithAPIURL(ctx context.Context, apiURL, userOrOrg string) ([]string, error) {
client := NewAuthenticatedClient(ctx, g.client)
client := NewAuthenticatedClient(ctx)
var allCloneURLs []string
url := fmt.Sprintf("%s/users/%s/repos", apiURL, userOrOrg)
isFirstRequest := true

View file

@ -154,7 +154,7 @@ func TestFindNextURL_Ugly(t *testing.T) {
func TestNewAuthenticatedClient_Good(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "test-token")
client := NewAuthenticatedClient(context.Background(), http.DefaultClient)
client := NewAuthenticatedClient(context.Background())
if client == http.DefaultClient {
t.Error("expected an authenticated client, but got http.DefaultClient")
}
@ -163,7 +163,7 @@ func TestNewAuthenticatedClient_Good(t *testing.T) {
func TestNewAuthenticatedClient_Bad(t *testing.T) {
// Unset the variable to ensure it's not present
t.Setenv("GITHUB_TOKEN", "")
client := NewAuthenticatedClient(context.Background(), http.DefaultClient)
client := NewAuthenticatedClient(context.Background())
if client != http.DefaultClient {
t.Error("expected http.DefaultClient when no token is set, but got something else")
}
@ -171,9 +171,9 @@ func TestNewAuthenticatedClient_Bad(t *testing.T) {
// setupMockClient is a helper function to inject a mock http.Client.
func setupMockClient(t *testing.T, mock *http.Client) *githubClient {
client := &githubClient{client: mock}
client := &githubClient{}
originalNewAuthenticatedClient := NewAuthenticatedClient
NewAuthenticatedClient = func(ctx context.Context, baseClient *http.Client) *http.Client {
NewAuthenticatedClient = func(ctx context.Context) *http.Client {
return mock
}
// Restore the original function after the test

View file

@ -1,23 +0,0 @@
package httpclient
import (
"net"
"net/http"
"time"
)
// NewClient creates a new http.Client with configurable timeouts.
func NewClient(totalTimeout, connectTimeout, tlsTimeout, headerTimeout time.Duration) *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: connectTimeout,
}).DialContext,
TLSHandshakeTimeout: tlsTimeout,
ResponseHeaderTimeout: headerTimeout,
}
return &http.Client{
Timeout: totalTimeout,
Transport: transport,
}
}

View file

@ -1,33 +0,0 @@
package httpclient
import (
"net/http"
"testing"
"time"
)
func TestNewClient(t *testing.T) {
totalTimeout := 10 * time.Second
connectTimeout := 2 * time.Second
tlsTimeout := 3 * time.Second
headerTimeout := 5 * time.Second
client := NewClient(totalTimeout, connectTimeout, tlsTimeout, headerTimeout)
if client.Timeout != totalTimeout {
t.Errorf("expected total timeout %v, got %v", totalTimeout, client.Timeout)
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected client transport to be *http.Transport, got %T", client.Transport)
}
if transport.TLSHandshakeTimeout != tlsTimeout {
t.Errorf("expected TLS handshake timeout %v, got %v", tlsTimeout, transport.TLSHandshakeTimeout)
}
if transport.ResponseHeaderTimeout != headerTimeout {
t.Errorf("expected response header timeout %v, got %v", headerTimeout, transport.ResponseHeaderTimeout)
}
}

View file

@ -31,8 +31,8 @@ type PWAClient interface {
}
// NewPWAClient creates a new PWAClient.
func NewPWAClient(client *http.Client) PWAClient {
return &pwaClient{client: client}
func NewPWAClient() PWAClient {
return &pwaClient{client: http.DefaultClient}
}
type pwaClient struct {

View file

@ -20,7 +20,7 @@ func TestFindManifest_Good(t *testing.T) {
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
expectedURL := server.URL + "/manifest.json"
actualURL, err := client.FindManifest(server.URL)
if err != nil {
@ -43,7 +43,7 @@ func TestFindManifest_Bad(t *testing.T) {
}
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
_, err := client.FindManifest(server.URL)
if err == nil {
t.Fatal("expected an error, but got none")
@ -55,7 +55,7 @@ func TestFindManifest_Bad(t *testing.T) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
_, err := client.FindManifest(server.URL)
if err == nil {
t.Fatal("expected an error for server error, but got none")
@ -70,7 +70,7 @@ func TestFindManifest_Ugly(t *testing.T) {
fmt.Fprint(w, `<html><head><link rel="manifest" href="first.json"><link rel="manifest" href="second.json"></head></html>`)
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
// Should find the first one
expectedURL := server.URL + "/first.json"
actualURL, err := client.FindManifest(server.URL)
@ -98,7 +98,7 @@ func TestFindManifest_Ugly(t *testing.T) {
}
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
expectedURL := server.URL + "/manifest.json"
actualURL, err := client.FindManifest(server.URL)
if err != nil {
@ -123,7 +123,7 @@ func TestFindManifest_Ugly(t *testing.T) {
}
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
expectedURL := server.URL + "/site.webmanifest"
actualURL, err := client.FindManifest(server.URL)
if err != nil {
@ -141,7 +141,7 @@ func TestDownloadAndPackagePWA_Good(t *testing.T) {
server := newPWATestServer()
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
bar := progressbar.NewOptions(1, progressbar.OptionSetWriter(io.Discard))
dn, err := client.DownloadAndPackagePWA(server.URL, server.URL+"/manifest.json", bar)
if err != nil {
@ -161,7 +161,7 @@ func TestDownloadAndPackagePWA_Bad(t *testing.T) {
t.Run("Bad Manifest URL", func(t *testing.T) {
server := newPWATestServer()
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
_, err := client.DownloadAndPackagePWA(server.URL, server.URL+"/nonexistent-manifest.json", nil)
if err == nil {
t.Fatal("expected an error for bad manifest url, but got none")
@ -178,7 +178,7 @@ func TestDownloadAndPackagePWA_Bad(t *testing.T) {
}
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
_, err := client.DownloadAndPackagePWA(server.URL, server.URL+"/manifest.json", nil)
if err == nil {
t.Fatal("expected an error for asset 404, but got none")
@ -198,7 +198,7 @@ func TestDownloadAndPackagePWA_Ugly(t *testing.T) {
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
dn, err := client.DownloadAndPackagePWA(server.URL, server.URL+"/manifest.json", nil)
if err != nil {
t.Fatalf("unexpected error for manifest with no assets: %v", err)
@ -214,7 +214,7 @@ func TestDownloadAndPackagePWA_Ugly(t *testing.T) {
// --- Test Cases for resolveURL ---
func TestResolveURL_Good(t *testing.T) {
client := NewPWAClient(http.DefaultClient).(*pwaClient)
client := NewPWAClient().(*pwaClient)
tests := []struct {
base string
ref string
@ -239,7 +239,7 @@ func TestResolveURL_Good(t *testing.T) {
}
func TestResolveURL_Bad(t *testing.T) {
client := NewPWAClient(http.DefaultClient).(*pwaClient)
client := NewPWAClient().(*pwaClient)
_, err := client.resolveURL("http://^invalid.com", "foo.html")
if err == nil {
t.Error("expected error for malformed base URL, but got nil")
@ -249,7 +249,7 @@ func TestResolveURL_Bad(t *testing.T) {
// --- Test Cases for extractAssetsFromHTML ---
func TestExtractAssetsFromHTML(t *testing.T) {
client := NewPWAClient(http.DefaultClient).(*pwaClient)
client := NewPWAClient().(*pwaClient)
t.Run("extracts stylesheets", func(t *testing.T) {
html := []byte(`<html><head><link rel="stylesheet" href="style.css"></head></html>`)
@ -427,7 +427,7 @@ func TestDownloadAndPackagePWA_FullManifest(t *testing.T) {
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
dn, err := client.DownloadAndPackagePWA(server.URL, server.URL+"/manifest.json", nil)
if err != nil {
t.Fatalf("DownloadAndPackagePWA failed: %v", err)
@ -495,7 +495,7 @@ func TestDownloadAndPackagePWA_ServiceWorker(t *testing.T) {
}))
defer server.Close()
client := NewPWAClient(http.DefaultClient)
client := NewPWAClient()
dn, err := client.DownloadAndPackagePWA(server.URL, server.URL+"/manifest.json", nil)
if err != nil {
t.Fatalf("DownloadAndPackagePWA failed: %v", err)

View file

@ -26,8 +26,13 @@ type Downloader struct {
errors []error
}
// NewDownloader creates a new Downloader with a custom http.Client.
func NewDownloader(maxDepth int, client *http.Client) *Downloader {
// NewDownloader creates a new Downloader.
func NewDownloader(maxDepth int) *Downloader {
return NewDownloaderWithClient(maxDepth, http.DefaultClient)
}
// NewDownloaderWithClient creates a new Downloader with a custom http.Client.
func NewDownloaderWithClient(maxDepth int, client *http.Client) *Downloader {
return &Downloader{
dn: datanode.New(),
visited: make(map[string]bool),
@ -38,13 +43,13 @@ func NewDownloader(maxDepth int, client *http.Client) *Downloader {
}
// downloadAndPackageWebsite downloads a website and packages it into a DataNode.
func downloadAndPackageWebsite(startURL string, maxDepth int, bar *progressbar.ProgressBar, client *http.Client) (*datanode.DataNode, error) {
func downloadAndPackageWebsite(startURL string, maxDepth int, bar *progressbar.ProgressBar) (*datanode.DataNode, error) {
baseURL, err := url.Parse(startURL)
if err != nil {
return nil, err
}
d := NewDownloader(maxDepth, client)
d := NewDownloader(maxDepth)
d.baseURL = baseURL
d.progressBar = bar
d.crawl(startURL, 0)

View file

@ -20,7 +20,7 @@ func TestDownloadAndPackageWebsite_Good(t *testing.T) {
defer server.Close()
bar := progressbar.NewOptions(1, progressbar.OptionSetWriter(io.Discard))
dn, err := DownloadAndPackageWebsite(server.URL, 2, bar, http.DefaultClient)
dn, err := DownloadAndPackageWebsite(server.URL, 2, bar)
if err != nil {
t.Fatalf("DownloadAndPackageWebsite failed: %v", err)
}
@ -52,7 +52,7 @@ func TestDownloadAndPackageWebsite_Good(t *testing.T) {
func TestDownloadAndPackageWebsite_Bad(t *testing.T) {
t.Run("Invalid Start URL", func(t *testing.T) {
_, err := DownloadAndPackageWebsite("http://invalid-url", 1, nil, http.DefaultClient)
_, err := DownloadAndPackageWebsite("http://invalid-url", 1, nil)
if err == nil {
t.Fatal("Expected an error for an invalid start URL, but got nil")
}
@ -63,7 +63,7 @@ func TestDownloadAndPackageWebsite_Bad(t *testing.T) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}))
defer server.Close()
_, err := DownloadAndPackageWebsite(server.URL, 1, nil, http.DefaultClient)
_, err := DownloadAndPackageWebsite(server.URL, 1, nil)
if err == nil {
t.Fatal("Expected an error for a server error on the start URL, but got nil")
}
@ -80,7 +80,7 @@ func TestDownloadAndPackageWebsite_Bad(t *testing.T) {
}))
defer server.Close()
// We expect an error because the link is broken.
dn, err := DownloadAndPackageWebsite(server.URL, 1, nil, http.DefaultClient)
dn, err := DownloadAndPackageWebsite(server.URL, 1, nil)
if err == nil {
t.Fatal("Expected an error for a broken link, but got nil")
}
@ -99,7 +99,7 @@ func TestDownloadAndPackageWebsite_Ugly(t *testing.T) {
defer server.Close()
bar := progressbar.NewOptions(1, progressbar.OptionSetWriter(io.Discard))
dn, err := DownloadAndPackageWebsite(server.URL, 1, bar, http.DefaultClient) // Max depth of 1
dn, err := DownloadAndPackageWebsite(server.URL, 1, bar) // Max depth of 1
if err != nil {
t.Fatalf("DownloadAndPackageWebsite failed: %v", err)
}
@ -122,7 +122,7 @@ func TestDownloadAndPackageWebsite_Ugly(t *testing.T) {
fmt.Fprint(w, `<a href="http://externalsite.com/page.html">External</a>`)
}))
defer server.Close()
dn, err := DownloadAndPackageWebsite(server.URL, 1, nil, http.DefaultClient)
dn, err := DownloadAndPackageWebsite(server.URL, 1, nil)
if err != nil {
t.Fatalf("DownloadAndPackageWebsite failed: %v", err)
}
@ -156,7 +156,7 @@ func TestDownloadAndPackageWebsite_Ugly(t *testing.T) {
// For now, we'll just test that it doesn't hang forever.
done := make(chan bool)
go func() {
_, err := DownloadAndPackageWebsite(server.URL, 1, nil, http.DefaultClient)
_, err := DownloadAndPackageWebsite(server.URL, 1, nil)
if err != nil && !strings.Contains(err.Error(), "context deadline exceeded") {
// We expect a timeout error, but other errors are failures.
t.Errorf("unexpected error: %v", err)