├── py ├── rsyncy ├── rsyncy-stat ├── .gitignore ├── setup.py ├── README.md └── rsyncy.py ├── rsyncy-stat ├── .github ├── FUNDING.yml └── workflows │ ├── lint-py.yml │ ├── ci.yml │ └── release.yml ├── .gitignore ├── scripts ├── lint ├── build ├── chkfmt └── xbuild ├── go.mod ├── .editorconfig ├── go.sum ├── LICENSE ├── README.md └── rsyncy.go /py/rsyncy: -------------------------------------------------------------------------------- 1 | rsyncy.py -------------------------------------------------------------------------------- /rsyncy-stat: -------------------------------------------------------------------------------- 1 | rsyncy -------------------------------------------------------------------------------- /py/rsyncy-stat: -------------------------------------------------------------------------------- 1 | rsyncy.py -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: laktak 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _* 2 | /rsyncy 3 | /dist 4 | -------------------------------------------------------------------------------- /py/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | *.egg-info/ 4 | *.pyc 5 | # setup script only 6 | Pipfile* 7 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eE -o pipefail 3 | 4 | script_dir=$(dirname "$(realpath "$0")") 5 | cd $script_dir/.. 6 | 7 | go vet -structtag=false -composites=false ./... 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/laktak/rsyncy/v2 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/laktak/lterm v1.5.0 9 | golang.org/x/term v0.36.0 10 | ) 11 | 12 | require golang.org/x/sys v0.37.0 // indirect 13 | -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eE -o pipefail 3 | 4 | script_dir=$(dirname "$(realpath "$0")") 5 | cd $script_dir/.. 6 | 7 | version=$(git describe --tags --always) 8 | echo build $version 9 | CGO_ENABLED=0 go build -ldflags="-X main.appVersion=$version" . 10 | -------------------------------------------------------------------------------- /scripts/chkfmt: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eE -o pipefail 3 | 4 | script_dir=$(dirname "$(realpath "$0")") 5 | cd $script_dir/.. 6 | 7 | res="$(gofmt -l . 2>&1)" 8 | 9 | if [ -n "$res" ]; then 10 | echo "gofmt check failed:" 11 | echo "${res}" 12 | exit 1 13 | fi 14 | -------------------------------------------------------------------------------- /.github/workflows/lint-py.yml: -------------------------------------------------------------------------------- 1 | 2 | name: lint-py 3 | 4 | on: [push, pull_request] 5 | 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-python@v5 12 | with: 13 | python-version: '3.12' 14 | - uses: psf/black@stable 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | # top-most EditorConfig file 6 | root = true 7 | 8 | [*] 9 | insert_final_newline = false 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | 13 | [*.py] 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [*.go] 18 | indent_style = tab 19 | indent_size = 4 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/laktak/lterm v1.5.0 h1:OWMkkVamT2g3Gl+K4opdtExrwxMfJ0himJX+mYPwBDs= 2 | github.com/laktak/lterm v1.5.0/go.mod h1:UsTPBogoejGbsc8OqaSBroXMBhzpyuFh1lfFjrmSPAo= 3 | golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 4 | golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 5 | golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= 6 | golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 7 | -------------------------------------------------------------------------------- /py/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | with open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf-8") as f: 5 | readme = f.read() 6 | 7 | setup( 8 | name="rsyncy", 9 | version="2.1.0", 10 | url="https://github.com/laktak/rsyncy", 11 | author="Christian Zangl", 12 | author_email="laktak@cdak.net", 13 | description="A status/progress bar for rsync.", 14 | long_description=readme, 15 | long_description_content_type="text/markdown", 16 | packages=[], 17 | install_requires=[], 18 | scripts=["rsyncy", "rsyncy-stat"], 19 | py_modules=["rsyncy"], 20 | ) 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [] 6 | pull_request: 7 | 8 | jobs: 9 | 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-go@v5 15 | with: 16 | go-version: "1.22" 17 | 18 | - name: chkfmt 19 | run: scripts/chkfmt 20 | 21 | # - name: tests 22 | # run: | 23 | # scripts/tests 24 | 25 | - name: xbuild 26 | run: scripts/xbuild 27 | 28 | - name: artifacts 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: prerelease-artifacts 32 | path: dist/* 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: ["v*"] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: "1.22" 19 | 20 | - name: chkfmt 21 | run: scripts/chkfmt 22 | 23 | # - name: tests 24 | # run: | 25 | # scripts/tests 26 | 27 | - name: xbuild 28 | run: version=${GITHUB_REF#$"refs/tags/v"} scripts/xbuild 29 | 30 | - name: release 31 | uses: softprops/action-gh-release@v2 32 | with: 33 | draft: true 34 | files: dist/* 35 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Christian Zangl 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 | 23 | -------------------------------------------------------------------------------- /scripts/xbuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eE -o pipefail 3 | 4 | script_dir=$(dirname "$(realpath "$0")") 5 | cd $script_dir/.. 6 | 7 | if [ -z "$version" ]; then 8 | version=$(git rev-parse HEAD) 9 | fi 10 | 11 | echo "building version $version" 12 | 13 | mkdir -p dist 14 | rm -f dist/* 15 | 16 | build() { 17 | echo "- $1-$2" 18 | rm -f dist/rsyncy 19 | CGO_ENABLED=0 GOOS="$1" GOARCH="$2" go build -o dist -ldflags="-X main.appVersion=$version" . 20 | 21 | pushd dist 22 | 23 | case "$1" in 24 | windows) 25 | outfile="rsyncy-$1-$2.zip" 26 | zip "$outfile" rsyncy.exe --move 27 | ;; 28 | *) 29 | outfile="rsyncy-$1-$2.tar.gz" 30 | tar -czf "$outfile" rsyncy --remove-files 31 | ;; 32 | esac 33 | 34 | popd 35 | } 36 | 37 | if [[ -z $2 ]]; then 38 | build android arm64 39 | build darwin amd64 40 | build darwin arm64 41 | build freebsd amd64 42 | build freebsd arm64 43 | build freebsd riscv64 44 | build linux amd64 45 | build linux arm64 46 | build linux riscv64 47 | build netbsd amd64 48 | build netbsd arm64 49 | build openbsd amd64 50 | build openbsd arm64 51 | build windows amd64 52 | build windows arm64 53 | else 54 | build $1 $2 55 | fi 56 | -------------------------------------------------------------------------------- /py/README.md: -------------------------------------------------------------------------------- 1 | 2 | # rsyncy (python version) 3 | 4 | A status/progress bar for [rsync](https://github.com/WayneD/rsync). 5 | 6 | ![gif of rsyncy -a a/ b](https://raw.githubusercontent.com/laktak/rsyncy/readme/readme/demo-y.gif "rsyncy -a a/ b") 7 | 8 | 9 | ## Status Bar 10 | 11 | ``` 12 | [########################::::::] 80% | 19.17G | 86.65MB/s | 0:03:18 | #306 | scan 46% (2410)\ 13 | ``` 14 | 15 | The status bar shows the following information: 16 | 17 | Description | Sample 18 | --- | --- 19 | Progress bar with percentage of the total transfer | `[########################::::::] 80%` 20 | Bytes transferred | `19.17G` 21 | Transfer speed | `86.65MB/s` 22 | Elapsed time since starting rsync | `0:03:18` 23 | Number of files transferred | `#306` 24 | Files to scan/check
- percentage completed
- (number of files)
- spinner | `scan 46% (2410)\` 25 | 26 | The spinner indicates that rsync is still checking if files need to be updated. Until this process completes the progress bar may decrease as new files are found. 27 | 28 | 29 | ## Installation 30 | 31 | See https://github.com/laktak/rsyncy 32 | 33 | 34 | ## Usage 35 | 36 | `rsyncy` is a wrapper around `rsync`. 37 | 38 | - You run `rsyncy` with the same arguments as it will pass them to `rsync` internally. 39 | - Do not specify any `--info` arguments, rsyncy will automatically add `--info=progress2` and `-hv` internally. 40 | 41 | ``` 42 | # simple example 43 | $ rsyncy -a FROM/ TO 44 | ``` 45 | 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # rsyncy 3 | 4 | { repos: [github.com/laktak/rsyncy](https://github.com/laktak/rsyncy/), [codeberg.org/laktak/rsyncy](https://codeberg.org/laktak/rsyncy) } 5 | 6 | A status/progress bar for [rsync](https://github.com/WayneD/rsync). 7 | 8 | ![gif of rsyncy -a a/ b](https://raw.githubusercontent.com/wiki/laktak/rsyncy/readme/demo.gif "rsyncy -a a/ b") 9 | 10 | 11 | - [Status Bar](#status-bar) 12 | - [Usage](#usage) 13 | - [Installation](#installation) 14 | - [Using ssh behind rsync (solved)](#using-ssh-behind-rsync-solved) 15 | - [lf (TUI) support](#lf-tui-support) 16 | - [Development](#development) 17 | 18 | 19 | ## Status Bar 20 | 21 | ``` 22 | [#######:::::::::::::::::::::::] 25% | 100.60M | 205.13kB/s | 0:00:22 | #3019 | 69% (4422..) 23 | [########################::::::] 82% | 367.57M | 508.23kB/s | 0:00:44 | #4234 | 85% of 5055 files 24 | ``` 25 | 26 | The status bar shows the following information: 27 | 28 | Description | Sample 29 | --- | --- 30 | (1) Progress bar with percentage of the total transfer | `[########################::::::] 80%` 31 | (2) Bytes transferred | `19.17G` 32 | (3) Transfer speed | `86.65MB/s` 33 | (4) Elapsed time since starting rsync | `0:03:18` 34 | (5) Number of files transferred | `#306` 35 | (6) Files
- percentage completed
- `*` spinner and `..` are shown while rsync is still scanning | `69% (4422..) *`
`85% of 5055 files` 36 | 37 | The spinner indicates that rsync is still looking for files. Until this process completes the progress bar may decrease as new files are found. 38 | 39 | 40 | ## Usage 41 | 42 | `rsyncy` is a wrapper around `rsync`. 43 | 44 | - You run `rsyncy` with the same arguments as it will pass them to `rsync` internally. 45 | - You do not need to specify any `--info` arguments as rsyncy will add them automatically (`--info=progress2 -hv`). 46 | 47 | ``` 48 | # simple example 49 | $ rsyncy -a FROM/ TO 50 | ``` 51 | 52 | Alternatively you can pipe the output from rsync to rsyncy (in which case you need to specify `--info=progress2 -hv` yourself). 53 | 54 | ``` 55 | $ rsync -a --info=progress2 -hv FROM/ TO | rsyncy 56 | ``` 57 | 58 | At the moment `rsyncy` itself has only one option, you can turn off colors via the `NO_COLOR=1` environment variable. 59 | 60 | 61 | ## Installation 62 | 63 | rsyncy is implemented in Go. For legacy reasons there is also a Python implementation that is still maintained. Both versions should behave exactly the same. 64 | 65 | 66 | ### Install/Update Binaries 67 | 68 | ``` 69 | curl https://laktak.github.io/rsyncy.sh|bash 70 | ``` 71 | 72 | This will download the rsyncy binary for your OS/Platform from the GitHub releases page and install it to `~/.local/bin`. You will get a message if that's not in your `PATH`. 73 | 74 | You probably want to download or view the [setup script](https://laktak.github.io/rsyncy.sh) before piping it to bash. 75 | 76 | If you prefer you can download a binary from [github.com/laktak/rsyncy/releases](https://github.com/laktak/rsyncy/releases) manually and place it in your `PATH`. 77 | 78 | 79 | ### Install via Homebrew (macOS and Linux) 80 | 81 | For macOS and Linux it can also be installed via [Homebrew](https://formulae.brew.sh/formula/rsyncy): 82 | 83 | ```shell 84 | $ brew install rsyncy 85 | ``` 86 | 87 | ### Install via Go 88 | 89 | ```shell 90 | $ go install github.com/laktak/rsyncy/v2@latest 91 | ``` 92 | 93 | ### Install on Arch/AUR 94 | 95 | For example with paru: 96 | 97 | ```shell 98 | $ paru -S rsyncy 99 | ``` 100 | 101 | ### Build from Source 102 | 103 | ```shell 104 | $ git clone https://github.com/laktak/rsyncy 105 | $ rsyncy/scripts/build 106 | 107 | # binary can be found here 108 | $ ls -l rsyncy/rsyncy 109 | ``` 110 | 111 | 112 | ## Using ssh behind rsync (solved) 113 | 114 | ssh uses direct TTY access to make sure that the input is indeed issued by an interactive keyboard user (for host keys and passwords). 115 | 116 | For this reason rsyncy now leaves one blank line between the output and the status bar. 117 | 118 | 119 | ## lf (TUI) support 120 | 121 | `rsyncy-stat` can be used to view only the status output on [lf](https://github.com/gokcehan/lf) (or similar terminal file managers). 122 | 123 | Example: 124 | 125 | ``` 126 | cmd paste-rsync %{{ 127 | opt="$@" 128 | set -- $(cat ~/.local/share/lf/files) 129 | mode="$1"; shift 130 | case "$mode" in 131 | copy) rsyncy-stat -rltphv $opt "$@" . ;; 132 | move) mv -- "$@" .; lf -remote "send clear" ;; 133 | esac 134 | }} 135 | ``` 136 | 137 | This shows the copy progress in the `>` line while rsync is running. 138 | 139 | If you have downloaded the binary version you can create it with `ln -s rsyncy rsyncy-stat`. 140 | 141 | 142 | ## Development 143 | 144 | First record an rsync transfer with [pipevcr](https://github.com/laktak/pipevcr), then replay it to rsyncy when debugging. 145 | 146 | -------------------------------------------------------------------------------- /rsyncy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "os/signal" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "syscall" 17 | "time" 18 | 19 | "github.com/laktak/lterm" 20 | "golang.org/x/term" 21 | ) 22 | 23 | var appVersion = "vdev" 24 | var reChk = regexp.MustCompile(`(..)-.+=(\d+)/(\d+)`) 25 | 26 | type Rstyle struct { 27 | bg string 28 | dim string 29 | text string 30 | bar1 string 31 | bar2 string 32 | spin string 33 | spinner []string 34 | } 35 | 36 | type Rsyncy struct { 37 | style Rstyle 38 | trans string 39 | percent float64 40 | speed string 41 | xfr string 42 | files string 43 | scanDone bool 44 | start time.Time 45 | statusOnly bool 46 | } 47 | 48 | func NewRsyncy(rstyle Rstyle) *Rsyncy { 49 | return &Rsyncy{ 50 | style: rstyle, 51 | start: time.Now(), 52 | } 53 | } 54 | 55 | func (r *Rsyncy) parseRsyncStat(line string) bool { 56 | // sample: 3.93M 5% 128.19kB/s 0:00:29 (xfr#208, ir-chk=2587/2821) 57 | // sample: 130.95M 29% 207.03kB/s 0:10:17 (xfr#4000, to-chk=1000/5055) 58 | data := strings.Fields(line) 59 | 60 | if len(data) >= 4 && 61 | strings.HasSuffix(data[1], "%") { 62 | r.trans = data[0] 63 | if p, err := strconv.ParseFloat(strings.TrimSuffix(data[1], "%"), 64); err == nil { 64 | r.percent = p / 100.0 65 | } else { 66 | // skip 67 | log.Printf("ERROR - can't parse#1: '%s' in %s\n", data[1], line) 68 | } 69 | r.speed = data[2] 70 | // ignore data[3] (time) 71 | 72 | if len(data) == 6 { 73 | xfr := strings.Split(strings.TrimSuffix(data[4], ","), "#") 74 | if len(xfr) == 2 { 75 | r.xfr = "#" + xfr[1] 76 | } 77 | 78 | match := reChk.FindStringSubmatch(data[5]) 79 | if len(match) == 4 { 80 | r.scanDone = match[1] == "to" 81 | todo, errTodo := strconv.Atoi(match[2]) 82 | total, errTotal := strconv.Atoi(match[3]) 83 | if errTodo == nil && errTotal == nil && total > 0 { 84 | done := total - todo 85 | percent := float64(done) * 100.0 / float64(total) 86 | r.scanDone = match[1] == "to" 87 | if r.scanDone { 88 | r.files = fmt.Sprintf("%.0f%% of %d files", percent, total) 89 | } else { 90 | r.files = fmt.Sprintf("%.0f%% (%d..) ", percent, total) 91 | } 92 | } else { 93 | r.files = "" 94 | } 95 | } else { 96 | r.files = "" 97 | } 98 | } 99 | return true 100 | } 101 | return false 102 | } 103 | 104 | func min(a, b int) int { 105 | if a < b { 106 | return a 107 | } 108 | return b 109 | } 110 | 111 | func formatDuration(d time.Duration) string { 112 | return fmt.Sprintf("%d:%02d:%02d", int(d.Hours()), int(d.Minutes())%60, int(d.Seconds())%60) 113 | } 114 | 115 | func (r *Rsyncy) drawStat() { 116 | cols := lterm.GetWidth() 117 | elapsed := time.Since(r.start) 118 | elapsedStr := formatDuration(elapsed) 119 | 120 | spin := "" 121 | if !r.scanDone && len(r.style.spinner) > 0 { 122 | spin = r.style.spinner[int(elapsed.Seconds())%len(r.style.spinner)] 123 | } 124 | 125 | // define status (excl. bar) 126 | // use \xff as a placeholder for the spinner 127 | parts := []string{ 128 | fmt.Sprintf("%11s", r.trans), 129 | fmt.Sprintf("%14s", r.speed), 130 | elapsedStr, 131 | r.xfr, 132 | r.files + "\xff", 133 | } 134 | 135 | // reduce to fit 136 | plen := func(parts []string) int { 137 | sum := 0 138 | for _, s := range parts { 139 | sum += len(s) 140 | } 141 | return sum + len(parts) 142 | } 143 | 144 | for len(parts) > 0 && plen(parts) > cols { 145 | parts = parts[1:] 146 | } 147 | 148 | // add bar in remaining space 149 | pc := 0 150 | rcols := cols - plen(parts) 151 | pcStr := "" 152 | if rcols > 12 { 153 | pcWidth := min(rcols-7, 30) 154 | pc = int(r.percent * float64(pcWidth)) 155 | pcStr = fmt.Sprintf("%s[%s%s%s%s]%s%4.0f%%", 156 | r.style.bar1, r.style.bar2, strings.Repeat("#", pc), 157 | r.style.bar1, strings.Repeat(":", pcWidth-pc), 158 | r.style.text, r.percent*100, 159 | ) 160 | rcols -= pcWidth + 7 161 | } else if rcols > 5 { 162 | pcStr = fmt.Sprintf("%5.0f%%", r.percent*100) 163 | rcols -= 5 164 | } 165 | if pcStr != "" { 166 | parts = append([]string{pcStr}, parts...) 167 | } 168 | 169 | // get delimiter size 170 | delim := fmt.Sprintf("%s|%s", r.style.dim, r.style.text) 171 | if rcols > (len(parts)-1)*2 { 172 | delim = " " + delim + " " 173 | } 174 | 175 | // render with delimiter 176 | status := strings.Replace(strings.Join(parts, delim), "\xff", r.style.spin+spin+r.style.text, 1) 177 | 178 | if !r.statusOnly { 179 | // keep a empty line above the bar: 180 | // if there is currently a prompt that writes directly to the tty (like requesting a password) 181 | // we can restore the position at the end 182 | lterm.Write( 183 | lterm.ClearLine(0), 184 | "\f", lterm.SaveCursor, 185 | "\r", r.style.bg, status, lterm.ClearLine(0), lterm.Reset, 186 | lterm.RestoreCursor, lterm.Move1(lterm.CursorUp)) 187 | } else { 188 | lterm.Write("\r\n", r.style.bg, status, lterm.Reset) 189 | } 190 | } 191 | 192 | func (r *Rsyncy) parseLine(lineBytes []byte, isStatHint bool) { 193 | line := string(bytes.TrimSpace(lineBytes)) 194 | if line == "" { 195 | return 196 | } 197 | 198 | isStat := isStatHint || bytes.HasPrefix(lineBytes, []byte("\r")) 199 | line = strings.TrimPrefix(line, "\r") 200 | 201 | if isStat { 202 | if !r.parseRsyncStat(line) && !r.statusOnly { 203 | lterm.Printline("\r", line) 204 | } 205 | r.drawStat() 206 | } else if strings.HasSuffix(line, "/") { 207 | // skip directories 208 | } else if !r.statusOnly { 209 | lterm.Printline("\r", line) 210 | r.drawStat() 211 | } 212 | } 213 | 214 | func (r *Rsyncy) readOutput(reader io.Reader) { 215 | bufReader := bufio.NewReader(reader) 216 | var lineBuffer bytes.Buffer 217 | ticker := time.NewTicker(200 * time.Millisecond) 218 | defer ticker.Stop() 219 | inputChan := make(chan byte, 1024) 220 | errChan := make(chan error, 1) 221 | 222 | // read bytes and send them to the channel 223 | go func() { 224 | for { 225 | b, err := bufReader.ReadByte() 226 | if err != nil { 227 | errChan <- err // signal error (including io.EOF) 228 | return 229 | } 230 | inputChan <- b 231 | } 232 | }() 233 | 234 | for { 235 | select { 236 | case b := <-inputChan: 237 | ticker.Reset(200 * time.Millisecond) 238 | 239 | if b == '\r' { 240 | r.parseLine(lineBuffer.Bytes(), true) 241 | lineBuffer.Reset() 242 | lineBuffer.WriteByte('\r') 243 | } else if b == '\n' { 244 | r.parseLine(lineBuffer.Bytes(), false) 245 | lineBuffer.Reset() 246 | } else { 247 | lineBuffer.WriteByte(b) 248 | } 249 | 250 | case <-ticker.C: 251 | // no new input 252 | if lineBuffer.Len() > 0 { 253 | // assume this is a status update 254 | r.parseLine(lineBuffer.Bytes(), true) 255 | lineBuffer.Reset() 256 | } else { 257 | r.drawStat() 258 | } 259 | 260 | case <-errChan: 261 | // exit 262 | r.parseLine(lineBuffer.Bytes(), false) 263 | lterm.Write("\r", lterm.ClearLine(0), "\n", lterm.ClearLine(0)) 264 | return 265 | } 266 | } 267 | } 268 | 269 | func runRsync(args []string, wg *sync.WaitGroup, rcChan chan<- int, stdoutWriter io.WriteCloser) { 270 | defer wg.Done() 271 | defer stdoutWriter.Close() 272 | 273 | // prefix rsync and add args required for progress 274 | args = append(args, "--info=progress2", "--no-v", "-hv") 275 | 276 | cmd := exec.Command("rsync", args...) 277 | cmd.Stdout = stdoutWriter 278 | cmd.Stderr = os.Stderr 279 | 280 | err := cmd.Run() 281 | exitCode := 0 282 | if err != nil { 283 | if exitErr, ok := err.(*exec.ExitError); ok { 284 | exitCode = exitErr.ExitCode() 285 | } else { 286 | log.Printf("Error running rsync: %v", err) 287 | exitCode = 1 288 | } 289 | } 290 | rcChan <- exitCode 291 | } 292 | 293 | func main() { 294 | log.SetFlags(0) 295 | 296 | var rstyle Rstyle 297 | var spinner = []string{"-", "\\", "|", "/"} 298 | if lterm.GetTermColorBits() >= 8 { 299 | rstyle = Rstyle{ 300 | bg: lterm.Bg8(238), 301 | dim: lterm.Fg8(241), 302 | text: lterm.Fg8(250), 303 | bar1: lterm.Fg8(243), 304 | bar2: lterm.Fg8(43), 305 | spin: lterm.Fg8(228) + lterm.Bold, 306 | spinner: spinner, 307 | } 308 | } else { 309 | rstyle = Rstyle{ 310 | bg: lterm.Bg4(7), 311 | dim: lterm.Fg4(8), 312 | text: lterm.Fg4(0), 313 | bar1: lterm.Fg4(8), 314 | bar2: lterm.Fg4(0), 315 | spin: lterm.Fg4(14) + lterm.Bold, 316 | spinner: spinner, 317 | } 318 | } 319 | 320 | rsyncy := NewRsyncy(rstyle) 321 | 322 | // status only mode (use with lf) 323 | if strings.HasSuffix(os.Args[0], "rsyncy-stat") { 324 | rsyncy.statusOnly = true 325 | } 326 | 327 | // handle ctrl+c 328 | sigChan := make(chan os.Signal, 1) 329 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 330 | go func() { 331 | <-sigChan 332 | lterm.Write(lterm.Reset, "\n", lterm.ClearLine(0), "\r\naborted\r\n") 333 | os.Exit(1) 334 | }() 335 | 336 | args := os.Args[1:] 337 | 338 | if len(args) == 0 { 339 | stdinFd := int(os.Stdin.Fd()) 340 | if term.IsTerminal(stdinFd) { 341 | fmt.Println("github.com/laktak/rsyncy") 342 | fmt.Println("Christian Zangl ") 343 | fmt.Println(appVersion) 344 | fmt.Println() 345 | fmt.Println("Usage: rsyncy SAME_OPTIONS_AS_RSYNC") 346 | fmt.Println("rsyncy is an rsync wrapper with a progress bar. All parameters will be passed to `rsync`.") 347 | } else { 348 | // receive pipe from rsync 349 | rsyncy.readOutput(os.Stdin) 350 | } 351 | } else { 352 | readPipe, writePipe, err := os.Pipe() 353 | if err != nil { 354 | log.Fatalf("error creating pipe: %v", err) 355 | os.Exit(1) 356 | } 357 | 358 | rcChan := make(chan int, 1) 359 | var wg sync.WaitGroup 360 | wg.Add(1) 361 | 362 | go runRsync(args, &wg, rcChan, writePipe) 363 | rsyncy.readOutput(readPipe) 364 | wg.Wait() 365 | exitCode := <-rcChan 366 | os.Exit(exitCode) 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /py/rsyncy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import queue 5 | import re 6 | import select 7 | import subprocess 8 | import sys 9 | import time 10 | import types 11 | from threading import Thread 12 | from datetime import datetime 13 | 14 | _re_chk = re.compile(r"(..)-.+=(\d+)/(\d+)") 15 | 16 | # start inline from laktakpy 17 | 18 | 19 | class CLI: 20 | NO_COLOR = os.environ.get("NO_COLOR", "") 21 | 22 | class style: 23 | reset = "\033[0m" 24 | bold = "\033[01m" 25 | 26 | class esc: 27 | right = "\033[C" 28 | 29 | def clear_line(opt=0): 30 | # 0=to end, 1=from start, 2=all 31 | return "\033[" + str(opt) + "K" 32 | 33 | def get_col_bits(): 34 | if CLI.NO_COLOR: 35 | return 1 36 | c, t = os.environ.get("COLORTERM", ""), os.environ.get("TERM", "") 37 | if c in ["truecolor", "24bit"]: 38 | return 24 39 | elif c == "8bit" or "256" in t: 40 | return 8 41 | else: 42 | return 4 43 | 44 | # 4bit system colors 45 | def fg4(col): 46 | # black=0,red=1,green=2,orange=3,blue=4,purple=5,cyan=6,lightgrey=7 47 | # darkgrey=8,lightred=9,lightgreen=10,yellow=11,lightblue=12,pink=13,lightcyan=14 48 | if CLI.NO_COLOR: 49 | return "" 50 | else: 51 | return f"\033[{(30+col) if col<8 else (90-8+col)}m" 52 | 53 | def bg4(col): 54 | # black=0,red=1,green=2,orange=3,blue=4,purple=5,cyan=6,lightgrey=7 55 | if CLI.NO_COLOR: 56 | return "" 57 | else: 58 | return f"\033[{40+col}m" 59 | 60 | # 8bit xterm colors 61 | def fg8(col): 62 | if CLI.NO_COLOR: 63 | return "" 64 | else: 65 | return f"\033[38;5;{col}m" 66 | 67 | def bg8(col): 68 | if CLI.NO_COLOR: 69 | return "" 70 | else: 71 | return f"\033[48;5;{col}m" 72 | 73 | def write(*text): 74 | for t in text: 75 | sys.stdout.write(str(t)) 76 | sys.stdout.flush() 77 | 78 | def printline(*text, sep=" ", end="\r\n"): 79 | CLI.write("\r", sep.join([str(t) for t in text]), CLI.esc.clear_line(), end) 80 | 81 | def get_size(): 82 | # returns {columns, lines} 83 | if sys.stdout.isatty(): 84 | return os.get_terminal_size() 85 | else: 86 | return types.SimpleNamespace(columns=80, lines=40) 87 | 88 | 89 | # end inline from laktakpy 90 | 91 | 92 | class Rsyncy: 93 | def __init__(self, rstyle): 94 | self.bg = rstyle["bg"] 95 | self.cdim = rstyle["dim"] 96 | self.ctext = rstyle["text"] 97 | self.cbar1 = rstyle["bar1"] 98 | self.cbar2 = rstyle["bar2"] 99 | self.cspin = rstyle["spin"] 100 | self.spinner = rstyle["spinner"] 101 | self.trans = 0 102 | self.percent = 0 103 | self.speed = "" 104 | self.xfr = "" 105 | self.files = "" 106 | self.scan_done = False 107 | self.start = datetime.now() 108 | self.stat_mode = False 109 | 110 | def parse_stat(self, line): 111 | # sample: 3.93M 5% 128.19kB/s 0:00:29 (xfr#208, ir-chk=2587/2821) 112 | # sample: 130.95M 29% 207.03kB/s 0:10:17 (xfr#4000, to-chk=1000/5055) 113 | data = [s for s in line.split(" ") if s] 114 | if len(data) >= 4 and data[1].endswith("%"): 115 | self.trans, percent, self.speed, timing, *_ = data 116 | try: 117 | self.percent = int(percent.strip("%")) / 100 118 | except Exception as e: 119 | print("ERROR - can't parse#1:", line, data, e, sep="\n> ") 120 | # ignore data[3] (time) 121 | 122 | if len(data) == 6: 123 | try: 124 | xfr, chk = data[4:6] 125 | self.xfr = xfr.strip(",").split("#")[1] 126 | if self.xfr: 127 | self.xfr = "#" + self.xfr 128 | 129 | m = _re_chk.match(chk) 130 | if m: 131 | self.scan_done = m[1] == "to" 132 | todo = int(m[2]) 133 | total = int(m[3]) 134 | done = total - todo 135 | if self.scan_done: 136 | self.files = ( 137 | f"{(done/total if total else 0):.0%} of {total} files" 138 | ) 139 | else: 140 | self.files = ( 141 | f"{(done/total if total else 0):.0%} ({total}..) " 142 | ) 143 | else: 144 | self.files = "" 145 | 146 | except Exception as e: 147 | print("ERROR - can't parse#2:", line, data, e, sep="\n> ") 148 | return True 149 | return False 150 | 151 | def draw_stat(self): 152 | cols = CLI.get_size().columns 153 | elapsed = datetime.now() - self.start 154 | if self.scan_done: 155 | spin = "" 156 | else: 157 | spin = self.spinner[round(elapsed.total_seconds()) % len(self.spinner)] 158 | 159 | # define status (excl. bar) 160 | # use \xff as a placeholder for the spinner 161 | parts = [ 162 | o 163 | for o in [ 164 | f"{self.trans:>11}", 165 | f"{self.speed:>14}", 166 | f"{str(elapsed).split('.')[0]}", 167 | f"{self.xfr}", 168 | f"{self.files}\xff", 169 | ] 170 | if o 171 | ] 172 | 173 | # reduce to fit 174 | plen = lambda: sum(len(s) for s in parts) + len(parts) 175 | while parts and plen() > cols: 176 | parts.pop(0) 177 | 178 | # add bar in remaining space 179 | pc = 0 180 | rcols = cols - plen() 181 | if rcols > 12: 182 | pc_width = min(rcols - 7, 30) 183 | pc = round(self.percent * pc_width) 184 | parts.insert( 185 | 0, 186 | f"{self.cbar1}[{self.cbar2}{'#' * pc}{self.cbar1}{':' * (pc_width-pc)}]{self.ctext}" 187 | + f"{self.percent:>5.0%}", 188 | ) 189 | rcols -= pc_width + 7 190 | elif rcols > 5: 191 | parts.insert(0, f"{self.percent:>5.0%}") 192 | rcols -= 5 193 | 194 | # get delimiter size 195 | delim = f"{self.cdim}|{self.ctext}" 196 | if rcols > (len(parts) - 1) * 2: 197 | delim = " " + delim + " " 198 | 199 | # render with delimiter 200 | status = delim.join(parts).replace("\xff", f"{self.cspin}{spin}{self.ctext}") 201 | 202 | if not self.stat_mode: 203 | # write and position cursor on bar 204 | CLI.write( 205 | "\r", 206 | self.bg, 207 | status, 208 | CLI.esc.clear_line(), 209 | "\r", 210 | f"{CLI.esc.right * pc}", 211 | CLI.style.reset, 212 | ) 213 | else: 214 | CLI.write("\r\n", self.bg, status, CLI.style.reset) 215 | 216 | def parse_line(self, line, is_stat): 217 | line = line.decode().strip(" ") 218 | if not line: 219 | return 220 | 221 | is_stat = is_stat or line[0] == "\r" 222 | line = line.replace("\r", "") 223 | 224 | if is_stat: 225 | if not self.parse_stat(line) and not self.stat_mode: 226 | CLI.printline(line) 227 | self.draw_stat() 228 | elif line[-1] == "/": 229 | # skip directories 230 | pass 231 | elif not self.stat_mode: 232 | CLI.printline(line) 233 | self.draw_stat() 234 | 235 | def read(self, fd): 236 | line = b"" 237 | while True: 238 | stat = select.select([fd], [], [], 0.2) 239 | if fd in stat[0]: 240 | ch = os.read(fd, 1) 241 | if ch == b"": 242 | # exit 243 | self.parse_line(line, False) 244 | break 245 | 246 | elif ch == b"\r": 247 | self.parse_line(line, True) 248 | line = b"\r" 249 | elif ch == b"\n": 250 | self.parse_line(line, False) 251 | line = b"" 252 | else: 253 | line += ch 254 | 255 | else: 256 | # no new input 257 | if line: 258 | # assume this is a status update 259 | self.parse_line(line, True) 260 | line = b"" 261 | else: 262 | # waiting for input 263 | self.draw_stat() 264 | time.sleep(0.5) 265 | 266 | CLI.printline("") 267 | 268 | 269 | def run_rsync(args, write_pipe, rc): 270 | # prefix rsync and add args required for progress 271 | args = ["rsync"] + args + ["--info=progress2", "--no-v", "-hv"] 272 | p = None 273 | try: 274 | p = subprocess.Popen(args, stdout=write_pipe) 275 | p.wait() 276 | finally: 277 | os.close(write_pipe) 278 | rc.put(p.returncode if p else 1) 279 | 280 | 281 | if __name__ == "__main__": 282 | if CLI.get_col_bits() >= 8: 283 | rstyle = { 284 | "bg": CLI.bg8(238), 285 | "dim": CLI.fg8(241), 286 | "text": CLI.fg8(250), 287 | "bar1": CLI.fg8(243), 288 | "bar2": CLI.fg8(43), 289 | "spin": CLI.fg8(228) + CLI.style.bold, 290 | "spinner": ["-", "\\", "|", "/"], 291 | } 292 | else: 293 | rstyle = { 294 | "bg": CLI.bg4(7), 295 | "dim": CLI.fg4(8), 296 | "text": CLI.fg4(0), 297 | "bar1": CLI.fg4(8), 298 | "bar2": CLI.fg4(0), 299 | "spin": CLI.fg4(14) + CLI.style.bold, 300 | "spinner": ["-", "\\", "|", "/"], 301 | } 302 | rsyncy = Rsyncy(rstyle) 303 | rsyncy.stat_mode = os.path.basename(sys.argv[0]) == "rsyncy-stat" 304 | 305 | try: 306 | if len(sys.argv) == 1: 307 | if sys.stdin.isatty(): 308 | print("github.com/laktak/rsyncy") 309 | print("Christian Zangl ") 310 | print() 311 | print("Usage: rsyncy SAME_OPTIONS_AS_RSYNC") 312 | print( 313 | "rsyncy is an rsync wrapper with a progress bar. All parameters will be passed to `rsync`." 314 | ) 315 | else: 316 | # receive pipe from rsync 317 | rsyncy.read(sys.stdin.fileno()) 318 | else: 319 | read_pipe, write_pipe = os.pipe() 320 | rc = queue.Queue() 321 | t = Thread(target=run_rsync, args=(sys.argv[1:], write_pipe, rc)) 322 | t.start() 323 | rsyncy.read(read_pipe) 324 | t.join() 325 | 326 | sys.exit(rc.get()) 327 | 328 | except KeyboardInterrupt: 329 | sys.exit(1) 330 | --------------------------------------------------------------------------------