├── 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 | 
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 | 
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 |
--------------------------------------------------------------------------------