├── .github ├── assets │ └── logo.png └── workflows │ ├── notifications.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd └── root.go ├── functionality ├── git.go └── handler.go ├── go.mod ├── go.sum ├── main.go ├── tests └── progress-manager-test.go └── utils ├── generics.go ├── json-helper.go └── manager.go /.github/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/backhub/c744d4716e7ddad71f986c2f3dd3ff91442a499d/.github/assets/logo.png -------------------------------------------------------------------------------- /.github/workflows/notifications.yml: -------------------------------------------------------------------------------- 1 | name: Custom Notifications 2 | on: 3 | schedule: 4 | - cron: '30 16 * * 6' # 4:30 pm UTC every saturday 5 | issues: 6 | types: [opened, edited, deleted, closed] 7 | issue_comment: 8 | types: [created] 9 | workflow_run: 10 | workflows: ["Release"] 11 | types: [completed] 12 | pull_request_target: 13 | types: [opened, closed, edited, review_requested] 14 | 15 | jobs: 16 | weekly-summary: 17 | if: github.event_name == 'schedule' 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Calculate Summary 21 | run: | 22 | REPO="${{ github.repository }}" 23 | STARS=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$REPO" | jq .stargazers_count) 24 | FORKS=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$REPO" | jq .forks_count) 25 | COMMITS=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ 26 | "https://api.github.com/repos/$REPO/commits?since=$(date -u -d 'last saturday' '+%Y-%m-%dT%H:%M:%SZ')" | jq length) 27 | curl -H "Content-Type: application/json" -X POST \ 28 | -d "{\"content\": \"*Weekly summary for **$REPO***\nStars - $STARS, Forks - $FORKS, Commits this week - $COMMITS\"}" ${{ secrets.DISCORD_WEBHOOK }} 29 | 30 | issue-comment-notification: 31 | if: github.event_name == 'issues' || github.event_name == 'issue_comment' 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Notify on Issue or Comment 35 | if: github.actor != 'Tanq16' 36 | run: | 37 | curl -H "Content-Type: application/json" -X POST \ 38 | -d "{\"content\": \"*New issue/comment from **${{ github.actor }}***\n${{ github.event.issue.html_url }}\"}" ${{ secrets.DISCORD_WEBHOOK }} 39 | 40 | build-status-notification: 41 | if: github.event_name == 'workflow_run' 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Notify on Build Status 45 | run: | 46 | curl -H "Content-Type: application/json" -X POST \ 47 | -d "{\"content\": \"*Workflow run for **${{ github.repository }}***\n${{ github.event.workflow_run.name }} - ${{ github.event.workflow_run.conclusion }}\"}" ${{ secrets.DISCORD_WEBHOOK }} 48 | 49 | pull-request-notification: 50 | if: github.event_name == 'pull_request_target' 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Notify on PR related activities 54 | if: github.actor != 'Tanq16' 55 | run: | 56 | curl -H "Content-Type: application/json" -X POST \ 57 | -d "{\"content\": \"*New PR activity from **${{ github.actor }}***\n${{ github.event.pull_request.html_url }}\"}" ${{ secrets.DISCORD_WEBHOOK }} 58 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | permissions: 8 | contents: write 9 | packages: write 10 | 11 | jobs: 12 | process-commit: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | version: ${{ steps.version.outputs.new_version }} 16 | release_created: ${{ steps.create_release.outputs.release_created }} 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Determine Version 23 | id: version 24 | run: | 25 | # Get the latest version tag 26 | LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") 27 | 28 | # Extract current version numbers 29 | VERSION_PARTS=(${LATEST_TAG//./ }) 30 | MAJOR=${VERSION_PARTS[0]#v} 31 | MINOR=${VERSION_PARTS[1]} 32 | PATCH=${VERSION_PARTS[2]:-0} 33 | 34 | # Check commit message for version increments 35 | if git log -1 --pretty=%B | grep -i "version bump"; then 36 | NEW_VERSION="v$((MAJOR + 1)).0" 37 | elif git log -1 --pretty=%B | grep -i "minor bump"; then 38 | NEW_VERSION="v$MAJOR.$((MINOR + 1)).0" 39 | elif git log -1 --pretty=%B | grep -i "patch bump"; then 40 | NEW_VERSION="v$MAJOR.$MINOR.$((PATCH + 1))" 41 | else 42 | NEW_VERSION="v$MAJOR.$MINOR.$((PATCH + 1))" 43 | fi 44 | 45 | echo "Previous version: $LATEST_TAG\n New version: $NEW_VERSION" 46 | echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" 47 | env: 48 | GH_TOKEN: ${{ github.token }} 49 | 50 | - name: Create Release 51 | id: create_release 52 | run: | 53 | gh release create "${{ steps.version.outputs.new_version }}" \ 54 | --title "Release ${{ steps.version.outputs.new_version }}" --draft \ 55 | --notes "BackHub - Latest (Version: ${{ steps.version.outputs.new_version }})" \ 56 | --target ${{ github.sha }} 57 | echo "release_created=true" >> "$GITHUB_OUTPUT" 58 | env: 59 | GH_TOKEN: ${{ github.token }} 60 | 61 | build: 62 | needs: process-commit 63 | runs-on: ubuntu-latest 64 | strategy: 65 | matrix: 66 | os: [linux, windows, darwin] 67 | arch: [amd64, arm64] 68 | steps: 69 | - uses: actions/checkout@v4 70 | 71 | - name: Set up Go 72 | uses: actions/setup-go@v5 73 | with: 74 | go-version: '1.24' 75 | 76 | - name: Build Binary 77 | run: | 78 | GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} \ 79 | go build -ldflags="-s -w -X github.com/tanq16/backhub/cmd.BackHubVersion=${{ needs.process-commit.outputs.version }}" -o "backhub${{ matrix.os == 'windows' && '.exe' || '' }}" . 80 | zip -r backhub-${{ matrix.os }}-${{ matrix.arch }}.zip backhub README.md LICENSE 81 | 82 | - name: Upload Release Asset 83 | run: | 84 | gh release upload "${{ needs.process-commit.outputs.version }}" "backhub-${{ matrix.os }}-${{ matrix.arch }}.zip" --clobber 85 | env: 86 | GH_TOKEN: ${{ github.token }} 87 | 88 | publish: 89 | needs: [process-commit, build] 90 | runs-on: ubuntu-latest 91 | steps: 92 | - uses: actions/checkout@v4 93 | 94 | - name: Publish Release 95 | run: | 96 | gh release edit "${{ needs.process-commit.outputs.version }}" --draft=false 97 | env: 98 | GH_TOKEN: ${{ github.token }} 99 | 100 | cleanup-on-failure: 101 | needs: [process-commit, build, publish] 102 | if: failure() && needs.process-commit.outputs.release_created == 'true' 103 | runs-on: ubuntu-latest 104 | steps: 105 | - name: Delete Draft Release 106 | run: | 107 | echo "Cleaning up draft release due to workflow failure" 108 | gh release delete "${{ needs.process-commit.outputs.version }}" --yes 109 | env: 110 | GH_TOKEN: ${{ github.token }} 111 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | backhub 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | go.work.sum 24 | 25 | # env file 26 | .env 27 | 28 | # yaml file 29 | .backhub.yaml 30 | backhub.yaml 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tanishq Rupaal 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | BackHub Logo 3 |

4 |

BackHub

5 |

6 | Release GitHub Release 7 |

8 | 9 |

10 | BackHub is a simple GitHub repository backup tool that creates complete local mirrors of your repositories. 11 |

12 | 13 | --- 14 | 15 | # Features 16 | 17 | - Full repository mirroring including all branches, tags, and history 18 | - Concurrent backup processing for multiple repositories defined in a YAML config file 19 | - GitHub token-based authentication (to be used in an environment variable) 20 | - Easy restoration capability due to it being local mirror 21 | - Multi-arch and multi-OS binary for simple one-time usage 22 | - A unique output provider with a fancy and detailed progress monitoring system 23 | 24 | # Installation 25 | 26 | ### Binary 27 | 28 | The easiest way to use Backhub is to download it from the [project releases](https://github.com/Tanq16/backhub/releases) for your OS and architecture. 29 | 30 | ### Development Build 31 | 32 | ```bash 33 | go install github.com/tanq16/backhub@latest 34 | ``` 35 | 36 | ### Build from Source 37 | 38 | ```bash 39 | git clone https://github.com/tanq16/backhub.git && \ 40 | cd backhub && \ 41 | go build 42 | ``` 43 | 44 | # Usage 45 | 46 | Backhub uses an environment variable to authenticate to GitHub. To do this, set your `GH_TOKEN` variable. This can be done inline with: 47 | 48 | ```bash 49 | GH_TOKEN=pat_jsdhksjdskhjdhkajshkdjh backhub 50 | ``` 51 | 52 | Alternatively, export it to your shell session with: 53 | 54 | ```bash 55 | export GH_TOKEN=pat_jsdhksjdskhjdhkajshkdjh 56 | ``` 57 | 58 | With the environment variable exported, `backhub` can be directly executed multiple times from the command line like so: 59 | 60 | ```bash 61 | # config file 62 | backhub /path/to/config.yaml 63 | 64 | # direct repo 65 | backhub github.com/tanq16/backhub 66 | ``` 67 | 68 | # YAML Config File 69 | 70 | BackHub uses a simple YAML configuration file: 71 | 72 | ```yaml 73 | repos: 74 | - github.com/username/repo1 75 | - github.com/username/repo2 76 | - github.com/org/repo3 77 | ``` 78 | 79 | For Docker, put the config file in the mounted directory and name it `config.yaml`. 80 | 81 | # Using the Local Mirrors 82 | 83 | To use a local mirror as a Git repository source (like when you need to restore from the backup), the following can be done: 84 | 85 | 1. Directly pull or clone from the mirror treating it as a `backup` remote in an existing repository: 86 | ```bash 87 | git remote add backup /path/to/your/mirror.git 88 | git pull backup main # or any other branch 89 | git clone /path/to/your/mirror.git new-repo 90 | ``` 91 | 2. Serve the mirror via a local git server and use it : 92 | ```bash 93 | git daemon --base-path=/path/to/mirror --export-all 94 | git clone git://localhost/mirror.git # in a different path 95 | ``` 96 | 3. Use the mirror as a git server by refering to it through the file protocol: 97 | ```bash 98 | git clone file:///path/to/mirror.git 99 | ``` 100 | 101 | Being a mirror, it contains all references (branches, tags, etc.), so cloning or pulling from it allows accessing everything as if it's the original. Use `git branch -a` to see all branches and `git tag -l` to see all tags in the mirror. 102 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/tanq16/backhub/functionality" 9 | ) 10 | 11 | var BackHubVersion = "dev" 12 | var unlimitedOutput bool 13 | 14 | var rootCmd = &cobra.Command{ 15 | Use: "backhub [config_file_or_repo]", 16 | Short: "GitHub repository backup tool using local mirrors", 17 | Version: BackHubVersion, 18 | Long: `BackHub is a simple GitHub repository backup tool that creates complete 19 | local mirrors of your repositories. It can backup repositories defined in a YAML 20 | configuration file or directly specified as a command line argument. 21 | 22 | Examples: 23 | backhub config.yaml # Backup repos from config file 24 | backhub github.com/username/repo # Backup a single repository`, 25 | Args: cobra.ExactArgs(1), 26 | Run: func(cmd *cobra.Command, args []string) { 27 | configPath := args[0] 28 | token := os.Getenv("GH_TOKEN") 29 | handler := functionality.NewHandler(token) 30 | err := handler.RunBackup(configPath, unlimitedOutput) 31 | if err != nil { 32 | fmt.Fprintf(os.Stderr, "Error: %s\n", err) 33 | os.Exit(1) 34 | } 35 | }, 36 | } 37 | 38 | func init() { 39 | rootCmd.Flags().BoolVar(&unlimitedOutput, "debug", false, "Show unlimited console output") 40 | } 41 | 42 | func Execute() { 43 | if err := rootCmd.Execute(); err != nil { 44 | fmt.Fprintln(os.Stderr, err) 45 | os.Exit(1) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /functionality/git.go: -------------------------------------------------------------------------------- 1 | package functionality 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/go-git/go-git/v5" 10 | "github.com/go-git/go-git/v5/plumbing/transport/http" 11 | ) 12 | 13 | // Handles the cloning or updating of a single repository 14 | func (h *Handler) backupRepo(repo, taskName string) error { 15 | folderName := getLocalFolderName(repo) 16 | repoURL := h.buildRepoURL(repo) 17 | h.outputMgr.AddStreamLine(taskName, fmt.Sprintf("Preparing to backup repository from %s", repoURL)) 18 | h.outputMgr.AddStreamLine(taskName, fmt.Sprintf("Target directory: %s", folderName)) 19 | // Set up authentication if token exists 20 | var auth *http.BasicAuth 21 | if h.token != "" { 22 | auth = &http.BasicAuth{ 23 | Username: "backhub", 24 | Password: h.token, 25 | } 26 | } 27 | // Check if repository exists locally 28 | if _, err := os.Stat(folderName); os.IsNotExist(err) { 29 | return h.cloneRepo(repoURL, folderName, auth, taskName) 30 | } 31 | h.outputMgr.AddStreamLine(taskName, "Repository exists locally, will update") 32 | return h.updateRepo(folderName, auth, taskName) 33 | } 34 | 35 | // Clones a repository as a mirror 36 | func (h *Handler) cloneRepo(repoURL, folderName string, auth *http.BasicAuth, taskName string) error { 37 | h.outputMgr.SetMessage(taskName, fmt.Sprintf("Cloning %s", repoURL)) 38 | h.outputMgr.AddStreamLine(taskName, "Starting clone operation") 39 | progress := &gitProgressWriter{ 40 | taskName: taskName, 41 | outputMgr: h.outputMgr, 42 | buffer: []string{}, 43 | lastUpdate: time.Now(), 44 | minInterval: 500 * time.Millisecond, 45 | } 46 | _, err := git.PlainClone(folderName, true, &git.CloneOptions{ 47 | URL: repoURL, 48 | Auth: auth, 49 | Mirror: true, 50 | Progress: progress, 51 | }) 52 | if err != nil { 53 | h.outputMgr.AddStreamLine(taskName, fmt.Sprintf("Clone failed: %s", err)) 54 | return fmt.Errorf("failed to clone repository: %w", err) 55 | } 56 | h.outputMgr.AddStreamLine(taskName, "Clone completed successfully") 57 | h.outputMgr.SetMessage(taskName, fmt.Sprintf("Successfully cloned %s", repoURL)) 58 | return nil 59 | } 60 | 61 | // Updates an existing repository 62 | func (h *Handler) updateRepo(folderName string, auth *http.BasicAuth, taskName string) error { 63 | h.outputMgr.SetMessage(taskName, fmt.Sprintf("Updating %s", folderName)) 64 | h.outputMgr.AddStreamLine(taskName, "Opening local repository") 65 | repo, err := git.PlainOpen(folderName) 66 | if err != nil { 67 | h.outputMgr.AddStreamLine(taskName, fmt.Sprintf("Failed to open repository: %s", err)) 68 | return fmt.Errorf("failed to open repository: %w", err) 69 | } 70 | h.outputMgr.AddStreamLine(taskName, "Fetching updates from remote") 71 | progress := &gitProgressWriter{ 72 | taskName: taskName, 73 | outputMgr: h.outputMgr, 74 | buffer: []string{}, 75 | lastUpdate: time.Now(), 76 | minInterval: 500 * time.Millisecond, 77 | } 78 | err = repo.Fetch(&git.FetchOptions{ 79 | Auth: auth, 80 | Force: true, 81 | Progress: progress, 82 | Tags: git.AllTags, 83 | }) 84 | if err == git.NoErrAlreadyUpToDate { 85 | h.outputMgr.AddStreamLine(taskName, "Repository already up to date") 86 | h.outputMgr.SetMessage(taskName, fmt.Sprintf("Repository %s is already up to date", folderName)) 87 | return nil 88 | } 89 | if err != nil { 90 | h.outputMgr.AddStreamLine(taskName, fmt.Sprintf("Fetch failed: %s", err)) 91 | return fmt.Errorf("failed to fetch updates: %w", err) 92 | } 93 | h.outputMgr.AddStreamLine(taskName, "Repository updated successfully") 94 | h.outputMgr.SetMessage(taskName, fmt.Sprintf("Successfully updated %s", folderName)) 95 | return nil 96 | } 97 | 98 | // Constructs the HTTPS URL for a repository 99 | func (h *Handler) buildRepoURL(repo string) string { 100 | return fmt.Sprintf("https://%s", repo) 101 | } 102 | 103 | // Generates the local folder name for a repository 104 | func getLocalFolderName(repo string) string { 105 | base := filepath.Base(repo) 106 | return base + ".git" 107 | } 108 | -------------------------------------------------------------------------------- /functionality/handler.go: -------------------------------------------------------------------------------- 1 | package functionality 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/tanq16/backhub/utils" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | type Config struct { 16 | Repos []string `yaml:"repos"` 17 | } 18 | 19 | type Handler struct { 20 | token string 21 | outputMgr *utils.Manager 22 | concurrency int 23 | repos []string 24 | cloneFolder string 25 | } 26 | 27 | // Implements io.Writer to capture git operation progress 28 | type gitProgressWriter struct { 29 | taskName string 30 | outputMgr *utils.Manager 31 | buffer []string 32 | lastUpdate time.Time 33 | minInterval time.Duration 34 | } 35 | 36 | // Implements io.Writer interface to capture git progress messages 37 | func (p *gitProgressWriter) Write(data []byte) (int, error) { 38 | message := strings.TrimSpace(string(data)) 39 | if message == "" { 40 | return len(data), nil 41 | } 42 | p.buffer = append(p.buffer, message) 43 | if len(p.buffer) > 5 { // Keep the last 5 messages 44 | p.buffer = p.buffer[len(p.buffer)-5:] 45 | } 46 | now := time.Now() 47 | if now.Sub(p.lastUpdate) >= p.minInterval { 48 | p.outputMgr.UpdateStreamOutput(p.taskName, p.buffer) 49 | p.lastUpdate = now 50 | } 51 | return len(data), nil 52 | } 53 | 54 | func NewHandler(token string) *Handler { 55 | return &Handler{ 56 | token: token, 57 | outputMgr: utils.NewManager(15), 58 | concurrency: 5, 59 | cloneFolder: ".", 60 | } 61 | } 62 | 63 | func (h *Handler) Setup() { 64 | h.outputMgr.Register("logistics") 65 | h.outputMgr.SetMessage("logistics", "Setting up BackHub") 66 | h.outputMgr.StartDisplay() 67 | } 68 | 69 | // Loads repository configuration from a file or direct repo path 70 | func (h *Handler) LoadConfig(path string) error { 71 | h.outputMgr.AddStreamLine("logistics", fmt.Sprintf("Loading configuration from '%s'", path)) 72 | repoRegex := "^github.com/[^/]+/[^/]+$" 73 | if regexp.MustCompile(repoRegex).MatchString(path) { 74 | h.repos = []string{path} 75 | h.outputMgr.AddStreamLine("logistics", "Direct repo specified, using it as configuration") 76 | return nil 77 | } 78 | data, err := os.ReadFile(path) 79 | if err != nil { 80 | h.outputMgr.AddStreamLine("logistics", "Failed to read config file") 81 | return fmt.Errorf("reading config file: %w", err) 82 | } 83 | var cfg Config 84 | if err := yaml.Unmarshal(data, &cfg); err != nil { 85 | h.outputMgr.AddStreamLine("logistics", "Failed to parse YAML configuration") 86 | return fmt.Errorf("parsing config: %w", err) 87 | } 88 | h.repos = cfg.Repos 89 | h.outputMgr.AddStreamLine("logistics", fmt.Sprintf("Loaded %d repositories", len(h.repos))) 90 | return nil 91 | } 92 | 93 | // Validates the GitHub token 94 | func (h *Handler) ValidateToken() error { 95 | if h.token == "" { 96 | h.outputMgr.AddStreamLine("logistics", "proceeding without GitHub token") 97 | h.outputMgr.SetStatus("logistics", "warning") 98 | } else { 99 | h.outputMgr.SetStatus("logistics", "pending") 100 | h.outputMgr.AddStreamLine("logistics", "GitHub token is set") 101 | } 102 | return nil 103 | } 104 | 105 | // Performs the backup operation for all repositories 106 | func (h *Handler) ExecuteBackup() error { 107 | repoCount := len(h.repos) 108 | wg := &sync.WaitGroup{} 109 | toProcess := make(chan string, repoCount) 110 | 111 | h.outputMgr.SetMessage("logistics", fmt.Sprintf("Processing %d repositories", repoCount)) 112 | wg.Add(1) 113 | go func() { 114 | defer wg.Done() 115 | for _, repo := range h.repos { 116 | toProcess <- repo 117 | } 118 | close(toProcess) 119 | }() 120 | 121 | // Start consumer pool 122 | for i := range h.concurrency { 123 | wg.Add(1) 124 | go func(workerId int) { 125 | defer wg.Done() 126 | for repo := range toProcess { 127 | taskName := fmt.Sprintf("repo-%s", repo) 128 | h.outputMgr.Register(taskName) 129 | h.outputMgr.SetMessage(taskName, fmt.Sprintf("Processing %s", repo)) 130 | if err := h.backupRepo(repo, taskName); err != nil { 131 | h.outputMgr.ReportError(taskName, err) 132 | } else { 133 | h.outputMgr.SetMessage(taskName, fmt.Sprintf("%s backed up successfully", repo)) 134 | h.outputMgr.Complete(taskName) 135 | } 136 | } 137 | }(i) 138 | } 139 | wg.Wait() 140 | 141 | // Final summary 142 | h.outputMgr.SetMessage("logistics", "Backup process completed") 143 | h.outputMgr.Complete("logistics") 144 | h.outputMgr.StopDisplay() 145 | return nil 146 | } 147 | 148 | // Entry point to run the backup process 149 | func (h *Handler) RunBackup(configPath string, unlimitedOutput bool) error { 150 | h.outputMgr.SetUnlimitedOutput(unlimitedOutput) 151 | h.Setup() 152 | if err := h.ValidateToken(); err != nil { 153 | h.outputMgr.ReportError("logistics", err) 154 | h.outputMgr.StopDisplay() 155 | return err 156 | } 157 | if err := h.LoadConfig(configPath); err != nil { 158 | h.outputMgr.ReportError("logistics", err) 159 | h.outputMgr.StopDisplay() 160 | return err 161 | } 162 | h.outputMgr.SetMessage("logistics", "Backup logistics completed") 163 | return h.ExecuteBackup() 164 | } 165 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tanq16/backhub 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/charmbracelet/lipgloss v1.1.0 7 | github.com/go-git/go-git/v5 v5.13.1 8 | github.com/spf13/cobra v1.8.1 9 | golang.org/x/term v0.27.0 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | 13 | require ( 14 | dario.cat/mergo v1.0.0 // indirect 15 | github.com/Microsoft/go-winio v0.6.1 // indirect 16 | github.com/ProtonMail/go-crypto v1.1.3 // indirect 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 19 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 21 | github.com/charmbracelet/x/term v0.2.1 // indirect 22 | github.com/cloudflare/circl v1.3.7 // indirect 23 | github.com/cyphar/filepath-securejoin v0.3.6 // indirect 24 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 25 | github.com/emirpasic/gods v1.18.1 // indirect 26 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 27 | github.com/go-git/go-billy/v5 v5.6.1 // indirect 28 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 31 | github.com/kevinburke/ssh_config v1.2.0 // indirect 32 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 33 | github.com/mattn/go-isatty v0.0.20 // indirect 34 | github.com/mattn/go-runewidth v0.0.16 // indirect 35 | github.com/muesli/termenv v0.16.0 // indirect 36 | github.com/pjbgf/sha1cd v0.3.0 // indirect 37 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 38 | github.com/rivo/uniseg v0.4.7 // indirect 39 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 40 | github.com/skeema/knownhosts v1.3.0 // indirect 41 | github.com/spf13/pflag v1.0.5 // indirect 42 | github.com/xanzy/ssh-agent v0.3.3 // indirect 43 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 44 | golang.org/x/crypto v0.31.0 // indirect 45 | golang.org/x/mod v0.19.0 // indirect 46 | golang.org/x/net v0.33.0 // indirect 47 | golang.org/x/sync v0.11.0 // indirect 48 | golang.org/x/sys v0.30.0 // indirect 49 | golang.org/x/tools v0.23.0 // indirect 50 | gopkg.in/warnings.v0 v0.1.2 // indirect 51 | ) 52 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 5 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 6 | github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= 7 | github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 8 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 14 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 15 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 16 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 17 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 18 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 19 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 21 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 22 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 23 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 24 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 25 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 26 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 27 | github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= 28 | github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 29 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 32 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 33 | github.com/elazarl/goproxy v1.2.3 h1:xwIyKHbaP5yfT6O9KIeYJR5549MXRQkoQMRXGztz8YQ= 34 | github.com/elazarl/goproxy v1.2.3/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= 35 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 36 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 37 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 38 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 39 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 40 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 41 | github.com/go-git/go-billy/v5 v5.6.1 h1:u+dcrgaguSSkbjzHwelEjc0Yj300NUevrrPphk/SoRA= 42 | github.com/go-git/go-billy/v5 v5.6.1/go.mod h1:0AsLr1z2+Uksi4NlElmMblP5rPcDZNRCD8ujZCRR2BE= 43 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 44 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 45 | github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M= 46 | github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc= 47 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 48 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 49 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 50 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 51 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 52 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 53 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 54 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 55 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 56 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 57 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 58 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 59 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 60 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 61 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 62 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 63 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 64 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 65 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 66 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 67 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 68 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 69 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 70 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 71 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 72 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 73 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 74 | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= 75 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 76 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 77 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 78 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 79 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 80 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 81 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 82 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 83 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 84 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 85 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 86 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 87 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 88 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 89 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 90 | github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= 91 | github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= 92 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 93 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 94 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 95 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 96 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 97 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 98 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 99 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 100 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 101 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 102 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 103 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 104 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 105 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 106 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 107 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 108 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 109 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 110 | golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= 111 | golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 112 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 113 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 114 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 115 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 116 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 117 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 118 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 119 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 120 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 121 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 122 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 123 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 125 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 126 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 127 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 128 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 129 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 130 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 131 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 132 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 133 | golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= 134 | golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= 135 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 136 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 137 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 138 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 139 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 140 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 141 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 142 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 143 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 144 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 145 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/tanq16/backhub/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /tests/progress-manager-test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "math/rand" 8 | "sync" 9 | "time" 10 | 11 | "github.com/tanq16/backhub/utils" 12 | ) 13 | 14 | // Example demonstrating the updated output manager functionality 15 | func main() { 16 | // Parse command line flags 17 | debugMode := flag.Bool("debug", false, "Enable debug mode with unlimited output") 18 | flag.Parse() 19 | 20 | // Create output manager with up to 15 lines per function 21 | outputMgr := utils.NewManager(15) 22 | outputMgr.SetUnlimitedOutput(*debugMode) 23 | outputMgr.StartDisplay() 24 | defer outputMgr.StopDisplay() 25 | 26 | // Register main coordinator function 27 | outputMgr.Register("coordinator") 28 | outputMgr.SetMessage("coordinator", "Starting BackHub demo with updated output manager") 29 | 30 | // Create a global statistics table 31 | statsTable := outputMgr.RegisterTable("Statistics", []string{"Operation", "Count", "Status"}) 32 | statsTable.Rows = append(statsTable.Rows, []string{"Repositories", "5", "Pending"}) 33 | statsTable.Rows = append(statsTable.Rows, []string{"Files", "0", "Pending"}) 34 | statsTable.Rows = append(statsTable.Rows, []string{"Bytes", "0", "Pending"}) 35 | 36 | // Sample repositories to process 37 | repos := []struct { 38 | Name string 39 | ShouldSucceed bool 40 | Size string 41 | Duration time.Duration 42 | }{ 43 | {"github.com/user/small-repo", true, "15MB", 2 * time.Second}, 44 | {"github.com/user/medium-repo", true, "120MB", 3 * time.Second}, 45 | {"github.com/user/large-repo", true, "450MB", 4 * time.Second}, 46 | {"github.com/org/private-repo", false, "85MB", 2 * time.Second}, 47 | {"github.com/org/shared-repo", true, "250MB", 3 * time.Second}, 48 | } 49 | 50 | // Process repositories concurrently 51 | var wg sync.WaitGroup 52 | totalBytes := 0 53 | totalFiles := 0 54 | 55 | // Add our progress reporter 56 | wg.Add(1) 57 | go runProgressReporter(outputMgr, &wg) 58 | 59 | for i, repo := range repos { 60 | wg.Add(1) 61 | // Start each repository processing in its own goroutine 62 | go func(idx int, repo struct { 63 | Name string 64 | ShouldSucceed bool 65 | Size string 66 | Duration time.Duration 67 | }) { 68 | defer wg.Done() 69 | 70 | // Create a unique task name 71 | taskName := fmt.Sprintf("repo-%d", idx) 72 | 73 | // Register with output manager 74 | outputMgr.Register(taskName) 75 | outputMgr.SetMessage(taskName, fmt.Sprintf("Processing %s", repo.Name)) 76 | 77 | // Create a function-specific table 78 | repoTable := outputMgr.RegisterFunctionTable(taskName, fmt.Sprintf("%s-details", repo.Name), []string{"Operation", "Status", "Duration"}) 79 | 80 | // Simulate processing steps 81 | steps := []string{ 82 | "Connecting to GitHub...", 83 | fmt.Sprintf("Repository size: %s", repo.Size), 84 | "Checking remote refs...", 85 | "Downloading objects...", 86 | "Resolving deltas...", 87 | "Checking out files...", 88 | } 89 | 90 | // Process each step 91 | bytesProcessed := 0 92 | filesProcessed := 0 93 | startTime := time.Now() 94 | stepDuration := repo.Duration / time.Duration(len(steps)) 95 | 96 | for i, step := range steps { 97 | // Simulate work 98 | time.Sleep(stepDuration) 99 | 100 | // Process random data for statistics 101 | stepBytes := rand.Intn(10000) + 1000 102 | stepFiles := rand.Intn(5) + 1 103 | bytesProcessed += stepBytes 104 | filesProcessed += stepFiles 105 | 106 | // Update global statistics (in a real app, use mutex for this) 107 | totalBytes += stepBytes 108 | totalFiles += stepFiles 109 | statsTable.Rows[1][1] = fmt.Sprintf("%d", totalFiles) 110 | statsTable.Rows[2][1] = fmt.Sprintf("%d KB", totalBytes/1024) 111 | 112 | // Log progress 113 | progress := float64(i+1) / float64(len(steps)) * 100 114 | outputMgr.UpdateStreamOutput(taskName, []string{fmt.Sprintf("%s (%.0f%%)", step, progress)}) 115 | 116 | // Add to repo table 117 | elapsed := time.Since(startTime).Round(100 * time.Millisecond) 118 | repoTable.Rows = append(repoTable.Rows, []string{step, "Complete", elapsed.String()}) 119 | 120 | // Add some randomized messages for more realistic output 121 | if rand.Float32() > 0.7 { 122 | extraMsg := getRandomProgressMessage() 123 | outputMgr.UpdateStreamOutput(taskName, []string{extraMsg}) 124 | } 125 | } 126 | 127 | // Complete with success or error 128 | if repo.ShouldSucceed { 129 | outputMgr.SetMessage(taskName, fmt.Sprintf("Successfully processed %s", repo.Name)) 130 | outputMgr.Complete(taskName) 131 | } else { 132 | err := errors.New("authentication failed: invalid token") 133 | outputMgr.ReportError(taskName, err) 134 | } 135 | 136 | }(i, repo) 137 | 138 | // Stagger starts for better visualization 139 | time.Sleep(500 * time.Millisecond) 140 | } 141 | 142 | // Update coordinator status 143 | outputMgr.SetMessage("coordinator", "Processing 5 repositories and running progress demo...") 144 | 145 | // Wait for all processing to complete 146 | wg.Wait() 147 | 148 | // Final update to the stats table 149 | statsTable.Rows[0][2] = "Complete" 150 | statsTable.Rows[1][2] = "Complete" 151 | statsTable.Rows[2][2] = "Complete" 152 | 153 | // Show completion message 154 | outputMgr.SetMessage("coordinator", "All repository processing has completed") 155 | outputMgr.Complete("coordinator") 156 | 157 | // Summary will be shown automatically when StopDisplay is called 158 | time.Sleep(500 * time.Millisecond) 159 | } 160 | 161 | // Returns random progress messages for variety 162 | func getRandomProgressMessage() string { 163 | messages := []string{ 164 | "Compressing objects...", 165 | "Network connection slower than expected", 166 | "Pruning redundant objects", 167 | "Optimizing local storage", 168 | "Received large pack file", 169 | "Processing reference changes", 170 | "Updating working copy", 171 | "Analyzing remote changes", 172 | "Verifying connectivity", 173 | } 174 | return messages[rand.Intn(len(messages))] 175 | } 176 | 177 | // runProgressReporter simulates a task with a progress bar and table output 178 | func runProgressReporter(mgr *utils.Manager, wg *sync.WaitGroup) { 179 | defer wg.Done() 180 | 181 | progressName := "progress-reporter" 182 | mgr.Register(progressName) 183 | mgr.SetMessage(progressName, "Running task with progress reporting") 184 | 185 | // Create a table for this function to track metrics 186 | metricsTable := mgr.RegisterFunctionTable(progressName, "progress-metrics", 187 | []string{"Checkpoint", "Elapsed Time", "Status", "Resource Usage"}) 188 | 189 | // Total steps for our task 190 | totalSteps := 20 191 | startTime := time.Now() 192 | checkpointTimes := make([]time.Time, 5) // Track time at specific checkpoints 193 | 194 | // Simulate work with progress updates 195 | for step := 1; step <= totalSteps; step++ { 196 | // Calculate percentage 197 | percentage := float64(step) / float64(totalSteps) * 100 198 | 199 | // Sleep to simulate work 200 | sleepTime := 200 + rand.Intn(300) 201 | time.Sleep(time.Duration(sleepTime) * time.Millisecond) 202 | 203 | // Add progress message 204 | status := fmt.Sprintf("Step %d of %d", step, totalSteps) 205 | mgr.AddProgressBarToStream(progressName, percentage, status) 206 | 207 | // Track specific checkpoints in our table 208 | elapsed := time.Since(startTime).Round(100 * time.Millisecond) 209 | 210 | // Add checkpoint entries at 25%, 50%, 75% and 100% 211 | if step == totalSteps/4 { 212 | checkpointTimes[0] = time.Now() 213 | metricsTable.Rows = append(metricsTable.Rows, []string{"25%", elapsed.String(), "In Progress", fmt.Sprintf("%d KB", rand.Intn(1000)+500)}) 214 | mgr.SetMessage(progressName, "Progress reporter at 25%") 215 | } else if step == totalSteps/2 { 216 | checkpointTimes[1] = time.Now() 217 | metricsTable.Rows = append(metricsTable.Rows, []string{"50%", elapsed.String(), "In Progress", fmt.Sprintf("%d KB", rand.Intn(1000)+1500)}) 218 | mgr.SetMessage(progressName, "Progress reporter at 50%") 219 | } else if step == totalSteps*3/4 { 220 | checkpointTimes[2] = time.Now() 221 | metricsTable.Rows = append(metricsTable.Rows, []string{"75%", elapsed.String(), "In Progress", fmt.Sprintf("%d KB", rand.Intn(1000)+2500)}) 222 | mgr.SetMessage(progressName, "Progress reporter at 75%") 223 | } else if step == totalSteps { 224 | checkpointTimes[3] = time.Now() 225 | metricsTable.Rows = append(metricsTable.Rows, []string{"100%", elapsed.String(), "Complete", fmt.Sprintf("%d KB", rand.Intn(1000)+3500)}) 226 | } 227 | } 228 | 229 | // Add a summary row 230 | totalElapsed := time.Since(startTime).Round(100 * time.Millisecond) 231 | metricsTable.Rows = append(metricsTable.Rows, []string{"Total", totalElapsed.String(), "Complete", "Complete"}) 232 | 233 | // Complete the task 234 | mgr.SetMessage(progressName, "Progress reporter completed") 235 | mgr.Complete(progressName) 236 | } 237 | -------------------------------------------------------------------------------- /utils/generics.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "slices" 5 | ) 6 | 7 | func SliceSame(slice1, slice2 []any) bool { 8 | if len(slice1) != len(slice2) { 9 | return false 10 | } 11 | for _, elem := range slice1 { 12 | if !slices.Contains(slice2, elem) { 13 | return false 14 | } 15 | } 16 | for _, elem := range slice2 { 17 | if !slices.Contains(slice1, elem) { 18 | return false 19 | } 20 | } 21 | return true 22 | } 23 | -------------------------------------------------------------------------------- /utils/json-helper.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | type Dictionary map[string]any 4 | 5 | // UnwindValue safely navigates and retrieves values from nested maps 6 | func (d Dictionary) UnwindValue(keys ...string) any { 7 | current := d 8 | for _, key := range keys { 9 | if current == nil { 10 | return nil 11 | } 12 | if val, ok := current[key]; ok { 13 | switch v := val.(type) { 14 | case map[string]any: 15 | current = Dictionary(v) 16 | case Dictionary: 17 | current = v 18 | default: 19 | return val // Gracefully return the value if it's not a map 20 | } 21 | } else { 22 | return nil 23 | } 24 | } 25 | return current 26 | } 27 | 28 | func (d Dictionary) UnwindString(keys ...string) string { 29 | if val := d.UnwindValue(keys...); val != nil { 30 | if str, ok := val.(string); ok { 31 | return str 32 | } 33 | } 34 | return "" 35 | } 36 | 37 | func (d Dictionary) UnwindBool(keys ...string) bool { 38 | if val := d.UnwindValue(keys...); val != nil { 39 | if b, ok := val.(bool); ok { 40 | return b 41 | } 42 | } 43 | return false 44 | } 45 | 46 | func (d Dictionary) UnwindFloat64(keys ...string) float64 { 47 | if val := d.UnwindValue(keys...); val != nil { 48 | switch v := val.(type) { 49 | case float64: 50 | return v 51 | case float32: 52 | return float64(v) 53 | case int: 54 | return float64(v) 55 | case int32: 56 | return float64(v) 57 | case int64: 58 | return float64(v) 59 | } 60 | } 61 | return 0.0 62 | } 63 | 64 | func (d Dictionary) UnwindFloat32(keys ...string) float32 { 65 | if val := d.UnwindValue(keys...); val != nil { 66 | switch v := val.(type) { 67 | case float32: 68 | return v 69 | case float64: 70 | return float32(v) 71 | case int: 72 | return float32(v) 73 | case int32: 74 | return float32(v) 75 | case int64: 76 | return float32(v) 77 | } 78 | } 79 | return 0.0 80 | } 81 | 82 | func (d Dictionary) UnwindInt64(keys ...string) int64 { 83 | if val := d.UnwindValue(keys...); val != nil { 84 | switch v := val.(type) { 85 | case int64: 86 | return v 87 | case int32: 88 | return int64(v) 89 | case int: 90 | return int64(v) 91 | case float64: 92 | return int64(v) 93 | case float32: 94 | return int64(v) 95 | } 96 | } 97 | return 0 98 | } 99 | 100 | func (d Dictionary) UnwindInt32(keys ...string) int32 { 101 | if val := d.UnwindValue(keys...); val != nil { 102 | switch v := val.(type) { 103 | case int32: 104 | return v 105 | case int64: 106 | return int32(v) 107 | case int: 108 | return int32(v) 109 | case float64: 110 | return int32(v) 111 | case float32: 112 | return int32(v) 113 | } 114 | } 115 | return 0 116 | } 117 | 118 | func (d Dictionary) UnwindInt(keys ...string) int { 119 | if val := d.UnwindValue(keys...); val != nil { 120 | switch v := val.(type) { 121 | case int: 122 | return v 123 | case int32: 124 | return int(v) 125 | case int64: 126 | return int(v) 127 | case float64: 128 | return int(v) 129 | case float32: 130 | return int(v) 131 | } 132 | } 133 | return 0 134 | } 135 | 136 | func (d Dictionary) UnwindUint64(keys ...string) uint64 { 137 | if val := d.UnwindValue(keys...); val != nil { 138 | switch v := val.(type) { 139 | case uint64: 140 | return v 141 | case uint32: 142 | return uint64(v) 143 | case uint: 144 | return uint64(v) 145 | case float64: 146 | return uint64(v) 147 | case float32: 148 | return uint64(v) 149 | } 150 | } 151 | return 0 152 | } 153 | 154 | func (d Dictionary) UnwindUint(keys ...string) uint { 155 | if val := d.UnwindValue(keys...); val != nil { 156 | switch v := val.(type) { 157 | case uint: 158 | return v 159 | case uint32: 160 | return uint(v) 161 | case uint64: 162 | return uint(v) 163 | case float64: 164 | return uint(v) 165 | case float32: 166 | return uint(v) 167 | } 168 | } 169 | return 0 170 | } 171 | 172 | func (d Dictionary) UnwindSlice(keys ...string) []any { 173 | if val := d.UnwindValue(keys...); val != nil { 174 | if slice, ok := val.([]any); ok { 175 | return slice 176 | } 177 | } 178 | return nil 179 | } 180 | 181 | func (d Dictionary) UnwindMap(keys ...string) Dictionary { 182 | if val := d.UnwindValue(keys...); val != nil { 183 | if m, ok := val.(Dictionary); ok { 184 | return m 185 | } 186 | if m, ok := val.(map[string]any); ok { 187 | return Dictionary(m) 188 | } 189 | } 190 | return nil 191 | } 192 | -------------------------------------------------------------------------------- /utils/manager.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/charmbracelet/lipgloss/table" 13 | ) 14 | 15 | var ( 16 | // Core styles 17 | successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green 18 | errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red 19 | warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // yellow 20 | pendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // blue 21 | infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan 22 | debugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) // light grey 23 | detailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("13")) // purple 24 | streamStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) // grey 25 | headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("69")) // purple 26 | 27 | // Additional config 28 | basePadding = 2 29 | ) 30 | 31 | var StyleSymbols = map[string]string{ 32 | "pass": "✓", 33 | "fail": "✗", 34 | "warning": "!", 35 | "pending": "○", 36 | "info": "ℹ", 37 | "arrow": "→", 38 | "bullet": "•", 39 | "dot": "·", 40 | } 41 | 42 | // ======================================== ================= 43 | // ======================================== Table Definitions 44 | // ======================================== ================= 45 | 46 | type Table struct { 47 | Headers []string 48 | Rows [][]string 49 | table *table.Table 50 | } 51 | 52 | func NewTable(headers []string) *Table { 53 | t := &Table{ 54 | Headers: headers, 55 | Rows: [][]string{}, 56 | } 57 | t.table = table.New().Headers(headers...) 58 | return t 59 | } 60 | 61 | func (t *Table) ReconcileRows() { 62 | if len(t.Rows) == 0 { 63 | return 64 | } 65 | for _, row := range t.Rows { 66 | t.table.Row(row...) 67 | } 68 | } 69 | 70 | func (t *Table) FormatTable(useMarkdown bool) string { 71 | t.ReconcileRows() 72 | if useMarkdown { 73 | return t.table.Border(lipgloss.MarkdownBorder()).String() 74 | } 75 | return t.table.String() 76 | } 77 | 78 | func (t *Table) PrintTable(useMarkdown bool) { 79 | os.Stdout.WriteString(t.FormatTable(useMarkdown)) 80 | } 81 | 82 | func (t *Table) WriteMarkdownTableToFile(outputPath string) error { 83 | return os.WriteFile(outputPath, []byte(t.FormatTable(true)), 0644) 84 | } 85 | 86 | // =========================================== ============== 87 | // =========================================== Output Manager 88 | // =========================================== ============== 89 | 90 | type FunctionOutput struct { 91 | Name string 92 | Status string 93 | Message string 94 | StreamLines []string 95 | Complete bool 96 | StartTime time.Time 97 | LastUpdated time.Time 98 | Error error 99 | Tables map[string]*Table // Function tables 100 | Index int 101 | } 102 | 103 | type ErrorReport struct { 104 | FunctionName string 105 | Error error 106 | Time time.Time 107 | } 108 | 109 | // Output manager main structure 110 | type Manager struct { 111 | outputs map[string]*FunctionOutput 112 | mutex sync.RWMutex 113 | numLines int 114 | maxStreams int // Max output stream lines per function 115 | unlimitedOutput bool // When true, unlimited output per function 116 | tables map[string]*Table // Global tables 117 | errors []ErrorReport 118 | doneCh chan struct{} // Channel to signal stopping the display 119 | pauseCh chan bool // Channel to pause/resume display updates 120 | isPaused bool 121 | displayTick time.Duration // Interval between display updates 122 | functionCount int 123 | displayWg sync.WaitGroup // WaitGroup for display goroutine shutdown 124 | } 125 | 126 | func NewManager(maxStreams int) *Manager { 127 | if maxStreams <= 0 { 128 | maxStreams = 15 // Default 129 | } 130 | return &Manager{ 131 | outputs: make(map[string]*FunctionOutput), 132 | tables: make(map[string]*Table), 133 | errors: []ErrorReport{}, 134 | maxStreams: maxStreams, 135 | unlimitedOutput: false, 136 | doneCh: make(chan struct{}), 137 | pauseCh: make(chan bool), 138 | isPaused: false, 139 | displayTick: 200 * time.Millisecond, // Default 140 | functionCount: 0, 141 | } 142 | } 143 | 144 | func (m *Manager) SetUnlimitedOutput(unlimited bool) { 145 | m.mutex.Lock() 146 | defer m.mutex.Unlock() 147 | m.unlimitedOutput = unlimited 148 | } 149 | 150 | func (m *Manager) SetUpdateInterval(interval time.Duration) { 151 | m.displayTick = interval 152 | } 153 | 154 | func (m *Manager) Pause() { 155 | if !m.isPaused { 156 | m.pauseCh <- true 157 | m.isPaused = true 158 | } 159 | } 160 | 161 | func (m *Manager) Resume() { 162 | if m.isPaused { 163 | m.pauseCh <- false 164 | m.isPaused = false 165 | } 166 | } 167 | 168 | func (m *Manager) Register(name string) { 169 | m.mutex.Lock() 170 | defer m.mutex.Unlock() 171 | m.functionCount++ 172 | m.outputs[name] = &FunctionOutput{ 173 | Name: name, 174 | Status: "pending", 175 | StreamLines: []string{}, 176 | StartTime: time.Now(), 177 | LastUpdated: time.Now(), 178 | Tables: make(map[string]*Table), 179 | Index: m.functionCount, 180 | } 181 | } 182 | 183 | func (m *Manager) SetMessage(name, message string) { 184 | m.mutex.Lock() 185 | defer m.mutex.Unlock() 186 | if info, exists := m.outputs[name]; exists { 187 | info.Message = message 188 | info.LastUpdated = time.Now() 189 | } 190 | } 191 | 192 | func (m *Manager) SetStatus(name, status string) { 193 | m.mutex.Lock() 194 | defer m.mutex.Unlock() 195 | if info, exists := m.outputs[name]; exists { 196 | info.Status = status 197 | info.LastUpdated = time.Now() 198 | } 199 | } 200 | 201 | func (m *Manager) GetStatus(name string) string { 202 | m.mutex.RLock() 203 | defer m.mutex.RUnlock() 204 | if info, exists := m.outputs[name]; exists { 205 | return info.Status 206 | } 207 | return "unknown" 208 | } 209 | 210 | func (m *Manager) Complete(name string) { 211 | m.mutex.Lock() 212 | defer m.mutex.Unlock() 213 | if info, exists := m.outputs[name]; exists { 214 | if !m.unlimitedOutput { 215 | info.StreamLines = []string{} 216 | } 217 | info.Complete = true 218 | info.Status = "success" 219 | info.LastUpdated = time.Now() 220 | } 221 | } 222 | 223 | func (m *Manager) ReportError(name string, err error) { 224 | m.mutex.Lock() 225 | defer m.mutex.Unlock() 226 | if info, exists := m.outputs[name]; exists { 227 | info.Complete = true 228 | info.Status = "error" 229 | info.Message = fmt.Sprintf("Error: %v", err) 230 | info.Error = err 231 | info.LastUpdated = time.Now() 232 | // Add to global error list 233 | m.errors = append(m.errors, ErrorReport{ 234 | FunctionName: name, 235 | Error: err, 236 | Time: time.Now(), 237 | }) 238 | } 239 | } 240 | 241 | func (m *Manager) UpdateStreamOutput(name string, output []string) { 242 | m.mutex.Lock() 243 | defer m.mutex.Unlock() 244 | if info, exists := m.outputs[name]; exists { 245 | if m.unlimitedOutput { // just append 246 | info.StreamLines = append(info.StreamLines, output...) 247 | } else { // enforce size limit 248 | currentLen := len(info.StreamLines) 249 | if currentLen+len(output) > m.maxStreams { 250 | startIndex := currentLen + len(output) - m.maxStreams 251 | if startIndex > currentLen { 252 | startIndex = 0 253 | } 254 | newLines := append(info.StreamLines[startIndex:], output...) 255 | if len(newLines) > m.maxStreams { 256 | newLines = newLines[len(newLines)-m.maxStreams:] 257 | } 258 | info.StreamLines = newLines 259 | } else { 260 | info.StreamLines = append(info.StreamLines, output...) 261 | } 262 | } 263 | info.LastUpdated = time.Now() 264 | } 265 | } 266 | 267 | func (m *Manager) AddStreamLine(name string, line string) { 268 | m.mutex.Lock() 269 | defer m.mutex.Unlock() 270 | if info, exists := m.outputs[name]; exists { 271 | if m.unlimitedOutput { // just append 272 | info.StreamLines = append(info.StreamLines, line) 273 | } else { // enforce size limit 274 | currentLen := len(info.StreamLines) 275 | if currentLen+1 > m.maxStreams { 276 | info.StreamLines = append(info.StreamLines[1:], line) 277 | } else { 278 | info.StreamLines = append(info.StreamLines, line) 279 | } 280 | } 281 | info.LastUpdated = time.Now() 282 | } 283 | } 284 | 285 | func (m *Manager) AddProgressBarToStream(name string, percentage float64, text string) { 286 | m.mutex.Lock() 287 | defer m.mutex.Unlock() 288 | if info, exists := m.outputs[name]; exists { 289 | percentage = max(0, min(percentage, 100)) 290 | progressBar := PrintProgressBar(int(percentage), 100, 30) 291 | display := progressBar + debugStyle.Render(text) 292 | info.StreamLines = []string{display} // Set as only stream so nothing else is displayed 293 | info.LastUpdated = time.Now() 294 | } 295 | } 296 | 297 | func PrintProgressBar(current, total int, width int) string { 298 | if width <= 0 { 299 | width = 30 300 | } 301 | percent := float64(current) / float64(total) 302 | filled := min(int(percent*float64(width)), width) 303 | bar := "(" 304 | bar += strings.Repeat(StyleSymbols["bullet"], filled) 305 | if filled < width { 306 | bar += ">" 307 | bar += strings.Repeat(" ", width-filled-1) 308 | } 309 | bar += ")" 310 | return debugStyle.Render(fmt.Sprintf("%s %.1f%% %s ", bar, percent*100, StyleSymbols["dot"])) 311 | } 312 | 313 | func (m *Manager) ClearLines(n int) { 314 | if n <= 0 { 315 | return 316 | } 317 | fmt.Printf("\033[%dA\033[J", n) 318 | m.numLines = max(m.numLines-n, 0) 319 | } 320 | 321 | func (m *Manager) ClearFunction(name string) { 322 | m.mutex.Lock() 323 | defer m.mutex.Unlock() 324 | if info, exists := m.outputs[name]; exists { 325 | info.StreamLines = []string{} 326 | info.Message = "" 327 | info.LastUpdated = time.Now() 328 | } 329 | } 330 | 331 | func (m *Manager) ClearAll() { 332 | m.mutex.Lock() 333 | defer m.mutex.Unlock() 334 | for name := range m.outputs { 335 | m.outputs[name].StreamLines = []string{} 336 | m.outputs[name].LastUpdated = time.Now() 337 | } 338 | } 339 | 340 | func (m *Manager) GetStatusIndicator(status string) string { 341 | switch status { 342 | case "success", "pass": 343 | return successStyle.Render(StyleSymbols["pass"]) 344 | case "error", "fail": 345 | return errorStyle.Render(StyleSymbols["fail"]) 346 | case "warning": 347 | return warningStyle.Render(StyleSymbols["warning"]) 348 | case "pending": 349 | return pendingStyle.Render(StyleSymbols["pending"]) 350 | default: 351 | return infoStyle.Render(StyleSymbols["bullet"]) 352 | } 353 | } 354 | 355 | // Add a global table 356 | func (m *Manager) RegisterTable(name string, headers []string) *Table { 357 | m.mutex.Lock() 358 | defer m.mutex.Unlock() 359 | table := NewTable(headers) 360 | m.tables[name] = table 361 | return table 362 | } 363 | 364 | // Adds a function-specific table 365 | func (m *Manager) RegisterFunctionTable(funcName string, name string, headers []string) *Table { 366 | m.mutex.Lock() 367 | defer m.mutex.Unlock() 368 | if info, exists := m.outputs[funcName]; exists { 369 | table := NewTable(headers) 370 | info.Tables[name] = table 371 | return table 372 | } 373 | return nil 374 | } 375 | 376 | func (m *Manager) sortFunctions() (active, pending, completed []struct { 377 | name string 378 | info *FunctionOutput 379 | }) { 380 | var allFuncs []struct { 381 | name string 382 | info *FunctionOutput 383 | index int 384 | } 385 | // Collect all functions 386 | for name, info := range m.outputs { 387 | allFuncs = append(allFuncs, struct { 388 | name string 389 | info *FunctionOutput 390 | index int 391 | }{name, info, info.Index}) 392 | } 393 | // Sort by index (registration order) 394 | sort.Slice(allFuncs, func(i, j int) bool { 395 | return allFuncs[i].index < allFuncs[j].index 396 | }) 397 | // Group functions by status 398 | for _, f := range allFuncs { 399 | if f.info.Complete { 400 | completed = append(completed, struct { 401 | name string 402 | info *FunctionOutput 403 | }{f.name, f.info}) 404 | } else if f.info.Status == "pending" && f.info.Message == "" { 405 | pending = append(pending, struct { 406 | name string 407 | info *FunctionOutput 408 | }{f.name, f.info}) 409 | } else { 410 | active = append(active, struct { 411 | name string 412 | info *FunctionOutput 413 | }{f.name, f.info}) 414 | } 415 | } 416 | return active, pending, completed 417 | } 418 | 419 | func (m *Manager) updateDisplay() { 420 | m.mutex.RLock() 421 | defer m.mutex.RUnlock() 422 | if m.numLines > 0 && !m.unlimitedOutput { 423 | fmt.Printf("\033[%dA\033[J", m.numLines) 424 | } 425 | lineCount := 0 426 | activeFuncs, pendingFuncs, completedFuncs := m.sortFunctions() 427 | 428 | // Display active functions 429 | for idx, f := range activeFuncs { 430 | info := f.info 431 | statusDisplay := m.GetStatusIndicator(info.Status) 432 | elapsed := time.Since(info.StartTime).Round(time.Millisecond) 433 | elapsedStr := fmt.Sprintf("[%s]", elapsed) 434 | 435 | // Style the message based on status 436 | var styledMessage string 437 | var prefixStyle lipgloss.Style 438 | switch info.Status { 439 | case "success": 440 | styledMessage = successStyle.Render(info.Message) 441 | prefixStyle = successStyle 442 | case "error": 443 | styledMessage = errorStyle.Render(info.Message) 444 | prefixStyle = errorStyle 445 | case "warning": 446 | styledMessage = warningStyle.Render(info.Message) 447 | prefixStyle = warningStyle 448 | default: // pending or other 449 | styledMessage = pendingStyle.Render(info.Message) 450 | prefixStyle = pendingStyle 451 | } 452 | functionPrefix := strings.Repeat(" ", basePadding) + prefixStyle.Render(fmt.Sprintf("%d. ", idx+1)) 453 | fmt.Printf("%s%s %s %s\n", functionPrefix, statusDisplay, debugStyle.Render(elapsedStr), styledMessage) 454 | lineCount++ 455 | 456 | // Print stream lines with indentation 457 | if len(info.StreamLines) > 0 { 458 | indent := strings.Repeat(" ", basePadding+4) // Additional indentation for stream output 459 | for _, line := range info.StreamLines { 460 | fmt.Printf("%s%s\n", indent, streamStyle.Render(line)) 461 | lineCount++ 462 | } 463 | } 464 | } 465 | 466 | // Display pending functions 467 | for idx, f := range pendingFuncs { 468 | info := f.info 469 | statusDisplay := m.GetStatusIndicator(info.Status) 470 | functionPrefix := strings.Repeat(" ", basePadding) + pendingStyle.Render(fmt.Sprintf("%d. ", len(activeFuncs)+idx+1)) 471 | fmt.Printf("%s%s %s\n", functionPrefix, statusDisplay, pendingStyle.Render("Waiting...")) 472 | lineCount++ 473 | if len(info.StreamLines) > 0 { 474 | indent := strings.Repeat(" ", basePadding+4) 475 | for _, line := range info.StreamLines { 476 | fmt.Printf("%s%s\n", indent, streamStyle.Render(line)) 477 | lineCount++ 478 | } 479 | } 480 | } 481 | 482 | // Display completed functions 483 | for idx, f := range completedFuncs { 484 | info := f.info 485 | statusDisplay := m.GetStatusIndicator(info.Status) 486 | totalTime := info.LastUpdated.Sub(info.StartTime).Round(time.Millisecond) 487 | timeStr := fmt.Sprintf("[%s]", totalTime) 488 | prefixStyle := successStyle 489 | if info.Status == "error" { 490 | prefixStyle = errorStyle 491 | } 492 | 493 | // Style message based on status 494 | var styledMessage string 495 | if info.Status == "success" { 496 | styledMessage = successStyle.Render(info.Message) 497 | } else if info.Status == "error" { 498 | prefixStyle = errorStyle 499 | styledMessage = errorStyle.Render(info.Message) 500 | } else if info.Status == "warning" { 501 | prefixStyle = warningStyle 502 | styledMessage = warningStyle.Render(info.Message) 503 | } else { // pending or other 504 | prefixStyle = pendingStyle 505 | styledMessage = pendingStyle.Render(info.Message) 506 | } 507 | functionPrefix := strings.Repeat(" ", basePadding) + prefixStyle.Render(fmt.Sprintf("%d. ", len(activeFuncs)+len(pendingFuncs)+idx+1)) 508 | fmt.Printf("%s%s %s %s\n", functionPrefix, statusDisplay, debugStyle.Render(timeStr), styledMessage) 509 | lineCount++ 510 | 511 | // Print stream lines with indentation if unlimited mode is enabled 512 | if m.unlimitedOutput && len(info.StreamLines) > 0 { 513 | indent := strings.Repeat(" ", basePadding+4) 514 | for _, line := range info.StreamLines { 515 | fmt.Printf("%s%s\n", indent, streamStyle.Render(line)) 516 | lineCount++ 517 | } 518 | } 519 | } 520 | m.numLines = lineCount 521 | } 522 | 523 | func (m *Manager) StartDisplay() { 524 | m.displayWg.Add(1) 525 | go func() { 526 | defer m.displayWg.Done() 527 | ticker := time.NewTicker(m.displayTick) 528 | defer ticker.Stop() 529 | for { 530 | select { 531 | case <-ticker.C: 532 | if !m.isPaused { 533 | m.updateDisplay() 534 | } 535 | case pauseState := <-m.pauseCh: 536 | m.isPaused = pauseState 537 | case <-m.doneCh: 538 | if !m.unlimitedOutput { 539 | m.ClearAll() 540 | } 541 | m.updateDisplay() 542 | m.ShowSummary() 543 | m.displayTables() 544 | return 545 | } 546 | } 547 | }() 548 | } 549 | 550 | func (m *Manager) StopDisplay() { 551 | close(m.doneCh) 552 | m.displayWg.Wait() // Wait for goroutine to finish 553 | } 554 | 555 | func (m *Manager) displayTables() { 556 | m.mutex.RLock() 557 | defer m.mutex.RUnlock() 558 | if len(m.tables) > 0 { 559 | fmt.Println(strings.Repeat(" ", basePadding) + headerStyle.Render("Global Tables:")) 560 | for name, table := range m.tables { 561 | fmt.Println(strings.Repeat(" ", basePadding+2) + headerStyle.Render(name)) 562 | fmt.Println(table.FormatTable(false)) 563 | } 564 | } 565 | // Display function tables 566 | hasFunctionTables := false 567 | for _, info := range m.outputs { 568 | if len(info.Tables) > 0 { 569 | hasFunctionTables = true 570 | break 571 | } 572 | } 573 | if hasFunctionTables { 574 | fmt.Println(strings.Repeat(" ", basePadding) + headerStyle.Render("Function Tables:")) 575 | for _, info := range m.outputs { 576 | if len(info.Tables) > 0 { 577 | fmt.Println(strings.Repeat(" ", basePadding+2) + headerStyle.Render(info.Name)) 578 | for tableName, table := range info.Tables { 579 | fmt.Println(strings.Repeat(" ", basePadding+4) + infoStyle.Render(tableName)) 580 | fmt.Println(table.FormatTable(false)) 581 | } 582 | } 583 | } 584 | } 585 | } 586 | 587 | func (m *Manager) displayErrors() { 588 | if len(m.errors) == 0 { 589 | return 590 | } 591 | fmt.Println() 592 | fmt.Println(strings.Repeat(" ", basePadding) + errorStyle.Bold(true).Render("Errors:")) 593 | for i, err := range m.errors { 594 | fmt.Printf("%s%s %s %s\n", 595 | strings.Repeat(" ", basePadding+2), 596 | errorStyle.Render(fmt.Sprintf("%d.", i+1)), 597 | debugStyle.Render(fmt.Sprintf("[%s]", err.Time.Format("15:04:05"))), 598 | errorStyle.Render(fmt.Sprintf("Function: %s", err.FunctionName))) 599 | fmt.Printf("%s%s\n", strings.Repeat(" ", basePadding+4), errorStyle.Render(fmt.Sprintf("Error: %v", err.Error))) 600 | } 601 | } 602 | 603 | func (m *Manager) ShowSummary() { 604 | m.mutex.RLock() 605 | defer m.mutex.RUnlock() 606 | fmt.Println() 607 | var success, failures int 608 | for _, info := range m.outputs { 609 | if info.Status == "success" { 610 | success++ 611 | } else if info.Status == "error" { 612 | failures++ 613 | } 614 | } 615 | totalOps := fmt.Sprintf("Total Operations: %d", len(m.outputs)) 616 | succeeded := fmt.Sprintf("Succeeded: %s", successStyle.Render(fmt.Sprintf("%d", success))) 617 | failed := fmt.Sprintf("Failed: %s", errorStyle.Render(fmt.Sprintf("%d", failures))) 618 | fmt.Println(infoStyle.Padding(0, basePadding).Render(fmt.Sprintf("%s, %s, %s", totalOps, succeeded, failed))) 619 | if m.unlimitedOutput { 620 | m.displayErrors() 621 | } 622 | fmt.Println() 623 | } 624 | --------------------------------------------------------------------------------