Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd9a4a169 | ||
|
|
d26a008763 |
@@ -1,7 +1,7 @@
|
|||||||
# dx
|
# dx
|
||||||
|
|
||||||
Zählt, was in einem Verzeichnis liegt, und wie schwer es ist — eine Ebene tief,
|
Counts what lies in a directory, and how heavy it is — one level deep, one
|
||||||
ein Blick, sofort fertig.
|
glance, done at once.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ dx ~/src -r -t 8
|
$ dx ~/src -r -t 8
|
||||||
@@ -17,15 +17,15 @@ $ dx ~/src -r -t 8
|
|||||||
… 16 more 1305 179M █▋ 16%
|
… 16 more 1305 179M █▋ 16%
|
||||||
```
|
```
|
||||||
|
|
||||||
Links der Name, dann die Zahl der enthaltenen Dateien, dann die Summe ihrer
|
On the left the name, then the number of files inside, then the sum of their
|
||||||
Größen, dann der Anteil am Ganzen. Die Kopfzeile ist das Verzeichnis selbst.
|
sizes, then the share of the whole. The header line is the directory itself.
|
||||||
Verzeichnisse werden vollständig durchlaufen, aber nicht aufgeklappt — für
|
Directories are walked through completely, but not unfolded — for everything
|
||||||
alles Weitere ist `ncdu` da.
|
beyond that there is `ncdu`.
|
||||||
|
|
||||||
Im Terminal ist die Ausgabe farbig, nach der Palette aus
|
In a terminal the output is coloured, after the palette from
|
||||||
[mwxcol](../mwxcol/README.md).
|
[mwxcol](../mwxcol/README.md).
|
||||||
|
|
||||||
## Bauen und installieren
|
## Building and installing
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd ~/src/dx
|
cd ~/src/dx
|
||||||
@@ -33,187 +33,228 @@ cd ~/src/dx
|
|||||||
cp bin/dx ~/bin/dx
|
cp bin/dx ~/bin/dx
|
||||||
```
|
```
|
||||||
|
|
||||||
`build.sh` baut nach `./bin`, je ein Binary für macOS und Linux auf arm64 und
|
`build.sh` builds into `./bin`, one binary each for macOS and Linux on arm64
|
||||||
amd64, und legt `bin/dx` als Symlink auf das für diese Maschine. Für eine
|
and amd64, and puts `bin/dx` there as a symlink to the one for this machine.
|
||||||
einzelne Plattform:
|
For a single platform:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
PLATFORMS="linux/amd64" ./build.sh
|
PLATFORMS="linux/amd64" ./build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Jeder Lauf zählt die Patch-Nummer in `version.txt` um eins hoch und baut sie
|
Every run counts the patch number in `version.txt` up by one and builds it into
|
||||||
per `-ldflags` in alle Binaries ein — `dx --version` sagt also, welcher Bau
|
all the binaries via `-ldflags` — so `dx --version` tells which build is
|
||||||
läuft. Ein nacktes `go build` geht auch, meldet dann aber die Vorgabe aus
|
running. A bare `go build` works too, but then reports the default from
|
||||||
`main.go` statt der wirklichen Nummer.
|
`main.go` instead of the real number.
|
||||||
|
|
||||||
Braucht Go ≥ 1.26 und `github.com/fatih/color`. Das Perl-Original (`dx`, 1.0.2,
|
Needs Go ≥ 1.26 and `github.com/fatih/color`. The Perl original (`dx`, 1.0.2,
|
||||||
2016) liegt unverändert daneben und wird vom Bau nicht angefasst; es hat keine
|
2016) lies next to it unchanged and is not touched by the build; it has no
|
||||||
Farben und keine Balken, tut aber sonst dasselbe.
|
colours and no bars, but otherwise does the same thing.
|
||||||
|
|
||||||
## Selbst aktualisieren
|
## Updating itself
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dx --check-update # nur nachsehen
|
dx --check-update # only look
|
||||||
dx --update # holen und ersetzen
|
dx --update # fetch and replace
|
||||||
```
|
```
|
||||||
|
|
||||||
`dx` holt sich das neueste Release von
|
`dx` fetches the newest release from
|
||||||
[git.micw.org/mike/dx](https://git.micw.org/mike/dx) — die URL steht fest im
|
[git.micw.org/mike/dx](https://git.micw.org/mike/dx) — the URL sits fixed in the
|
||||||
Programm, es gibt nichts zu konfigurieren. Gebraucht wird ein Release je
|
program, there is nothing to configure. What it needs is one release per
|
||||||
Version, dessen Tag die nackte Nummer ist (`2.1.6`), mit den Dateien aus `./bin`
|
version, whose tag is the bare number (`2.1.6`), with the files from `./bin` as
|
||||||
als Assets; gesucht wird die eine, die zu `GOOS`/`GOARCH` dieser Maschine passt.
|
its assets; the one looked for is the one matching `GOOS`/`GOARCH` of this
|
||||||
|
machine.
|
||||||
|
|
||||||
Ersetzt wird die laufende Datei selbst. Ist der aufgerufene `dx` ein Symlink —
|
What gets replaced is the running file itself. If the `dx` that was called is a
|
||||||
etwa `~/bin/dx` auf `~/src/dx/bin/dx-darwin-arm64` —, wird das Ziel dahinter
|
symlink — say `~/bin/dx` pointing at `~/src/dx/bin/dx-darwin-arm64` — the target
|
||||||
erneuert, nicht der Link. Vor dem Tausch wird das frisch Geladene einmal mit
|
behind it is renewed, not the link. Before the swap, what was freshly fetched is
|
||||||
`--version` aufgerufen; meldet es nicht die erwartete Nummer, bleibt alles, wie
|
called once with `--version`; if it does not report the expected number,
|
||||||
es war. Der Tausch selbst ist ein `rename` im selben Verzeichnis, also atomar:
|
everything stays as it was. The swap itself is a `rename` within the same
|
||||||
entweder die alte Datei oder die neue, nie eine halbe.
|
directory, hence atomic: either the old file or the new one, never half of one.
|
||||||
|
|
||||||
Liegt das Binary irgendwo, wo der eigene Benutzer nicht schreiben darf
|
If the binary lies somewhere the user may not write to (`/usr/local/bin`),
|
||||||
(`/usr/local/bin`), sagt `dx --update` das und tut nichts — dann `sudo`.
|
`dx --update` says so and does nothing — then `sudo`.
|
||||||
|
|
||||||
## Wiederverwenden
|
### Once a day, by itself
|
||||||
|
|
||||||
`selfupdate.go` hängt an nichts im Rest des Programms und kommt mit der
|
Without being asked, `dx` looks once a day and afterwards says so at the end of
|
||||||
Standardbibliothek aus. Für ein anderes Programm die Datei kopieren, den Block
|
the output, on stderr:
|
||||||
oben anpassen —
|
|
||||||
|
```
|
||||||
|
dx 2.1.9 is available, run 'dx --update'
|
||||||
|
```
|
||||||
|
|
||||||
|
The run in the foreground never touches the network for this. It only reads a
|
||||||
|
note — `~/Library/Caches/dx/update.json`, on Linux `~/.cache/dx/update.json` —
|
||||||
|
and when that one is older than a day, it starts `dx --update-refresh` on the
|
||||||
|
side: the same runner once more, detached, without output, only to ask. Nobody
|
||||||
|
waits for its answer; it will be in the note at the next call. `dx` thereby
|
||||||
|
stays exactly as fast as before, even when the server happens to be silent.
|
||||||
|
|
||||||
|
The timestamp moves on *before* the asking. Two simultaneous runs therefore
|
||||||
|
start one query, not two, and a server that does not answer is asked again
|
||||||
|
tomorrow rather than on every call. If the note cannot be written, the question
|
||||||
|
is dropped entirely — otherwise a write-protected cache directory would mean one
|
||||||
|
process per call.
|
||||||
|
|
||||||
|
Asking and speaking happen only when stderr hangs on a terminal. In a pipe, in a
|
||||||
|
script and under cron there is quiet, and `DX_NO_UPDATE_CHECK=1` turns it off
|
||||||
|
altogether.
|
||||||
|
|
||||||
|
This costs space: `net/http` together with TLS turns a 1.8 MB binary into one of
|
||||||
|
6.2 MB. For a tool that sits on the disk anyway, that is the price of being able
|
||||||
|
to renew itself — whoever will not pay it leaves `selfupdate.go` out.
|
||||||
|
|
||||||
|
## Reusing it
|
||||||
|
|
||||||
|
`selfupdate.go` hangs on nothing in the rest of the program and gets by with the
|
||||||
|
standard library. For another program, copy the file, adjust the block at the
|
||||||
|
top —
|
||||||
|
|
||||||
```go
|
```go
|
||||||
var selfUpdate = selfUpdater{
|
var selfUpdate = selfUpdater{
|
||||||
repo: "https://git.micw.org/mike/foo",
|
repo: "https://git.micw.org/mike/foo",
|
||||||
asset: "foo",
|
asset: "foo",
|
||||||
current: version,
|
current: version,
|
||||||
verify: []string{"--version"},
|
verify: []string{"--version"},
|
||||||
|
every: 24 * time.Hour,
|
||||||
|
quietEnv: "FOO_NO_UPDATE_CHECK",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
— und `--update` und `--check-update` in die Optionen hängen. Alles Weitere in
|
— plus three lines in the options part: `--update`, `--check-update` and the
|
||||||
der Datei heißt `selfUpdate…` oder `update…` und kollidiert deshalb nicht.
|
hidden `updateRefreshFlag`, and one at the end of the program:
|
||||||
`verify` leer lassen, wenn das Programm keinen billigen Versionsaufruf hat; dann
|
|
||||||
entfällt der Probelauf. Erwartet wird eine Gitea-Instanz (getestet mit 1.27);
|
|
||||||
GitHub kennt dieselbe Route unter anderen Feldnamen und wird nicht bedient.
|
|
||||||
|
|
||||||
## Optionen
|
```go
|
||||||
|
if hint := selfUpdate.daily(); hint != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, hint)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`daily` delivers only the text; what it looks like is for the program to decide.
|
||||||
|
Everything else in the file is called `selfUpdate…` or `update…` and therefore
|
||||||
|
does not collide. Leave `verify` empty when the program has no cheap version
|
||||||
|
call, and the trial run is skipped; `every: 0` turns off the looking by itself
|
||||||
|
and leaves just the two options. What is expected is a Gitea instance (tested
|
||||||
|
with 1.27); GitHub knows the same route under different field names and is not
|
||||||
|
served.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
| | | |
|
| | | |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `-n` | `--number` | nach Dateizahl sortieren statt nach Größe |
|
| `-n` | `--number` | sort by number of files instead of by size |
|
||||||
| `-d` | `--dirs` | nur Verzeichnisse zählen, Dateien der obersten Ebene weglassen |
|
| `-d` | `--dirs` | count only directories, leave out top-level files |
|
||||||
| `-r` | `--reverse` | größtes zuerst |
|
| `-r` | `--reverse` | largest first |
|
||||||
| `-t N` | `--top=N` | nur die N größten zeigen, den Rest zu einer Zeile summieren |
|
| `-t N` | `--top=N` | show only the N largest, sum the rest into one line |
|
||||||
| `-h` | `--help` | Hilfe |
|
| `-h` | `--help` | help |
|
||||||
| | `--bar`, `--no-bar` | Balken und Prozent erzwingen bzw. abschalten |
|
| | `--bar`, `--no-bar` | force bar and percentage, or switch them off |
|
||||||
| | `--color=auto\|always\|never` | Farbe erzwingen bzw. abschalten |
|
| | `--color=auto\|always\|never` | force colour, or switch it off |
|
||||||
| | `--version` | Version |
|
| | `--version` | version |
|
||||||
| | `--check-update` | nachsehen, ob ein neueres Release da ist |
|
| | `--check-update` | look whether a newer release is there |
|
||||||
| | `--update` | neuestes Release holen und installieren |
|
| | `--update` | fetch and install the newest release |
|
||||||
|
|
||||||
Kurzoptionen lassen sich bündeln (`-rt 3`, `-dn`), der Wert von `-t` darf kleben
|
Short options can be bundled (`-rt 3`, `-dn`), the value of `-t` may stick
|
||||||
(`-t5`) oder folgen (`-t 5`). Ein `--` trennt Optionen von einem Pfad, der mit
|
(`-t5`) or follow (`-t 5`). A `--` separates options from a path beginning with
|
||||||
`-` beginnt. Ohne Pfad gilt das aktuelle Verzeichnis.
|
`-`. Without a path, the current directory applies.
|
||||||
|
|
||||||
Sortiert wird aufsteigend, das Größte steht also direkt über dem Prompt und man
|
Sorting is ascending, so the largest stands right above the prompt and there is
|
||||||
muss nicht scrollen. `-r` dreht das um, für Pipes und lange Listen.
|
no need to scroll. `-r` turns that around, for pipes and long lists.
|
||||||
|
|
||||||
Bei `-t` steht die Sammelzeile immer am kleinen Ende: oben bei normaler
|
With `-t`, the summary line always sits at the small end: at the top under
|
||||||
Sortierung, unten bei `-r`.
|
normal sorting, at the bottom with `-r`.
|
||||||
|
|
||||||
| Exit | |
|
| Exit | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 0 | alles gelesen |
|
| 0 | everything read |
|
||||||
| 1 | Pfad existiert nicht oder ist kein Verzeichnis |
|
| 1 | path does not exist or is not a directory |
|
||||||
| 2 | Option falsch benutzt |
|
| 2 | option used wrongly |
|
||||||
|
|
||||||
## Farbe und Balken
|
## Colour and bars
|
||||||
|
|
||||||
Beides gilt am Terminal und entfällt in einer Pipe — `dx | sort` bekommt reinen
|
Both apply at the terminal and fall away in a pipe — `dx | sort` gets plain
|
||||||
Text. `--color=always` schaltet beides wieder ein, für `less -R`. Wer nur eins
|
text. `--color=always` switches both back on, for `less -R`. Whoever wants only
|
||||||
davon will, sagt es mit `--bar` / `--no-bar` ausdrücklich. `NO_COLOR` wird
|
one of them says so explicitly with `--bar` / `--no-bar`. `NO_COLOR` is
|
||||||
beachtet.
|
respected.
|
||||||
|
|
||||||
Die Farben folgen den `file_type`-Rollen aus `themes/eza/theme.yml`, damit
|
The colours follow the `file_type` roles from `themes/eza/theme.yml`, so that
|
||||||
dieselbe Datei in `eza` und `dx` gleich aussieht:
|
the same file looks the same in `eza` and in `dx`:
|
||||||
|
|
||||||
| | | | | |
|
| | | | | |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Verzeichnis | blue, fett | | Archive | orange |
|
| directory | blue, bold | | archives | orange |
|
||||||
| ausführbar | green, fett | | Bilder | violet |
|
| executable | green, bold | | images | violet |
|
||||||
| Symlink | pink | | Video | pink |
|
| symlink | pink | | video | pink |
|
||||||
| Quelltext, Konfiguration | blue | | Audio | green |
|
| source, configuration | blue | | audio | green |
|
||||||
| README, Makefile, `go.mod` | yellow | | Schlüssel, Zertifikate | red |
|
| README, Makefile, `go.mod` | yellow | | keys, certificates | red |
|
||||||
| Dokumente | white | | Kompilate (`.o`, `.dylib`) | grey |
|
| documents | white | | compiled (`.o`, `.dylib`) | grey |
|
||||||
| sonstige Dateien | white | | `.tmp`, `.bak`, `.DS_Store` | darker |
|
| other files | white | | `.tmp`, `.bak`, `.DS_Store` | darker |
|
||||||
|
|
||||||
Die Zahlenspalte ist grey, eine Null dark. Die Größenspalte trägt die
|
The number column is grey, a zero dark. The size column carries the size ramp of
|
||||||
Größenrampe des eza-Themes — bis K green, M yellow, G orange, T red — und der
|
the eza theme — up to K green, M yellow, G orange, T red — and the bar takes on
|
||||||
Balken nimmt die Farbe der Spalte auf, deren Anteil er zeigt: die der Größe,
|
the colour of the column whose share it shows: that of the size, with `-n` that
|
||||||
mit `-n` die der Dateizahl.
|
of the file count.
|
||||||
|
|
||||||
Der Balken ist zehn Zellen breit und rechnet in Achteln (`▏▎▍▌▋▊▉█`). Was
|
The bar is ten cells wide and reckons in eighths (`▏▎▍▌▋▊▉█`). Whatever amounts
|
||||||
weniger als ein halbes Prozent ausmacht, bekommt keinen Strich und steht als
|
to less than half a percent gets no stroke and stands there as `<1%` instead of
|
||||||
`<1%` da statt als geschöntes `0%`. Dadurch bleiben genau die Zeilen sichtbar,
|
a flattering `0%`. That way exactly those lines stay visible that matter.
|
||||||
auf die es ankommt.
|
|
||||||
|
|
||||||
## Größen
|
## Sizes
|
||||||
|
|
||||||
Die Schwellen sind die des Originals: ab 1024 der jeweiligen Einheit wird
|
The thresholds are those of the original: from 1024 of the unit in question it
|
||||||
umgeschaltet. Unterhalb von 10 kommt eine Nachkommastelle dazu, weil `2G` sonst
|
switches over. Below 10, one decimal is added, because `2G` could otherwise mean
|
||||||
alles zwischen 1,5 und 2,4 Gigabyte bedeuten könnte. Ganze Bytes bleiben ganz.
|
anything between 1.5 and 2.4 gigabytes. Whole bytes stay whole.
|
||||||
|
|
||||||
8 4.9K 316M 1.1G
|
8 4.9K 316M 1.1G
|
||||||
|
|
||||||
## Was genau gezählt wird
|
## What exactly gets counted
|
||||||
|
|
||||||
Gezählt werden **reguläre Dateien**, wie bei `find -type f`. Verzeichnisse,
|
What gets counted are **regular files**, as with `find -type f`. Directories,
|
||||||
Sockets, Fifos und Geräte zählen nicht mit, auch nicht die Verzeichnisse selbst.
|
sockets, fifos and devices do not count, nor do the directories themselves.
|
||||||
|
|
||||||
* Ein **Symlink auf ein Verzeichnis** wird nicht verfolgt und steht deshalb mit
|
* A **symlink to a directory** is not followed and therefore stands there with
|
||||||
`0` da — pink eingefärbt, damit die Null erklärt ist.
|
`0` — coloured pink, so that the zero is explained.
|
||||||
* Ein **Symlink auf eine Datei** auf oberster Ebene zählt mit der Größe seines
|
* A **symlink to a file** at the top level counts with the size of its target,
|
||||||
Ziels, ebenfalls pink.
|
likewise pink.
|
||||||
* **Hardlinks** zählen so oft, wie sie vorkommen. In einem Baum mit
|
* **Hardlinks** count as often as they occur. In a tree made with
|
||||||
`rsync --link-dest` oder Time Machine sind die Zahlen dadurch zu hoch; für so
|
`rsync --link-dest` or Time Machine the numbers come out too high; `dx` is not
|
||||||
etwas ist `dx` nicht gedacht.
|
meant for that sort of thing.
|
||||||
* Gemessen wird die **Dateigröße**, nicht der belegte Platz. Sparse-Dateien und
|
* What is measured is the **file size**, not the space occupied. Sparse files
|
||||||
APFS-Klone erscheinen größer, als sie auf der Platte sind.
|
and APFS clones appear larger than they are on the disk.
|
||||||
* Was sich nicht lesen lässt — gesperrte Verzeichnisse, tote Symlinks — wird
|
* What cannot be read — locked directories, dead symlinks — is skipped and
|
||||||
übersprungen und am Ende auf **stderr** gemeldet, damit die Tabelle heil
|
reported at the end on **stderr**, so that the table stays intact.
|
||||||
bleibt.
|
* `-d` leaves out top-level files entirely, from the sum as well.
|
||||||
* `-d` lässt Dateien der obersten Ebene ganz weg, auch aus der Summe.
|
|
||||||
|
|
||||||
Unterverzeichnisse werden nebenläufig durchlaufen, so viele auf einmal wie
|
Subdirectories are walked concurrently, as many at a time as there are cores.
|
||||||
Kerne da sind. Das Original startete pro Verzeichnis einen `find`-Prozess; auf
|
The original started one `find` process per directory; on `~/go` that makes
|
||||||
`~/go` sind das 0,19 s statt 0,34 s.
|
0.19 s instead of 0.34 s.
|
||||||
|
|
||||||
## Unterschiede zum Perl-Original
|
## Differences from the Perl original
|
||||||
|
|
||||||
1. **Gleichstände sortieren nach Namen.** Perl ließ sie in der Reihenfolge des
|
1. **Ties sort by name.** Perl left them in the order of the hash, and `dx -n`
|
||||||
Hashes, `dx -n` gab bei jedem Aufruf eine andere Reihenfolge aus.
|
gave a different order on every call.
|
||||||
2. **Ein ungültiger Pfad ist ein Fehler.** Perl listete stillschweigend das
|
2. **An invalid path is an error.** Perl silently listed the current directory —
|
||||||
aktuelle Verzeichnis — plausible, aber falsche Zahlen.
|
plausible numbers, but the wrong ones.
|
||||||
3. **Die Namensspalte ist eine Stelle breiter**, wenn der längste Eintrag ein
|
3. **The name column is one place wider** when the longest entry is a directory.
|
||||||
Verzeichnis ist. Perl zählte den angehängten `/` bei der Spaltenbreite nicht
|
Perl did not count the appended `/` towards the column width, and the longest
|
||||||
mit, der längste Name klebte am Zahlenblock.
|
name stuck to the block of numbers.
|
||||||
4. **Fehler stehen auf stderr**, statt als `find: … Permission denied` mitten in
|
4. **Errors go to stderr**, instead of appearing as `find: … Permission denied`
|
||||||
der Ausgabe.
|
in the middle of the output.
|
||||||
5. **Größen haben eine Nachkommastelle**, siehe oben. Das ist die einzige
|
5. **Sizes have one decimal**, see above. That is the only deviation in the old
|
||||||
Abweichung im alten Ausgabeformat; ohne Farbe und Balken ist der Rest
|
output format; without colour and bars the rest is identical byte for byte.
|
||||||
byteweise identisch.
|
|
||||||
|
|
||||||
Neu sind `-r`, `-t`, die Balkenspalte, die Farben und die langen Optionen.
|
New are `-r`, `-t`, the bar column, the colours and the long options.
|
||||||
|
|
||||||
## Aufbau
|
## Layout
|
||||||
|
|
||||||
| Datei | |
|
| File | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main.go` | Zählen, Sortieren, Formatieren, Optionen |
|
| `main.go` | counting, sorting, formatting, options |
|
||||||
| `filetypes.go` | Endung und Dateiname → Farbe |
|
| `filetypes.go` | extension and file name → colour |
|
||||||
| `selfupdate.go` | `--update`, eigenständig und zum Kopieren gedacht |
|
| `selfupdate.go` | `--update`, self-contained and meant for copying |
|
||||||
| `build.sh` | Bau für alle Plattformen, zählt `version.txt` hoch |
|
| `build.sh` | build for all platforms, counts `version.txt` up |
|
||||||
| `version.txt` | die zuletzt gebaute Version |
|
| `version.txt` | the version built last |
|
||||||
| `dx` | das Perl-Original von 2016 |
|
| `dx` | the Perl original from 2016 |
|
||||||
|
|
||||||
Die Palette steht in `main.go` doppelt — einmal als Zahlenwerte, einmal in den
|
The palette sits in `main.go` twice — once as numeric values, once in the
|
||||||
Kommentaren als Rolle. Quelle der Wahrheit bleibt `~/src/mwxcol/mwxcol.go`;
|
comments as a role. The source of truth remains `~/src/mwxcol/mwxcol.go`; if it
|
||||||
ändert sie sich dort, müssen die elf `rgb{…}` hier nachgezogen werden.
|
changes there, the eleven `rgb{…}` here have to be brought along.
|
||||||
|
|||||||
+12
-12
@@ -8,9 +8,9 @@ import (
|
|||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dateityp → Farbe, nach den file_type-Rollen des eza-Themes
|
// File type → colour, after the file_type roles of the eza theme
|
||||||
// (~/src/mwxcol/themes/eza/theme.yml), damit dieselbe Datei in eza und dx
|
// (~/src/mwxcol/themes/eza/theme.yml), so that the same file has the same
|
||||||
// dieselbe Farbe hat.
|
// colour in eza and in dx.
|
||||||
var byExt = map[string]*color.Color{}
|
var byExt = map[string]*color.Color{}
|
||||||
|
|
||||||
func register(c *color.Color, exts ...string) {
|
func register(c *color.Color, exts ...string) {
|
||||||
@@ -20,7 +20,7 @@ func register(c *color.Color, exts ...string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// source — blau
|
// source — blue
|
||||||
register(cBlue,
|
register(cBlue,
|
||||||
"go", "c", "h", "cc", "cpp", "cxx", "hpp", "rs", "py", "pl", "pm", "t",
|
"go", "c", "h", "cc", "cpp", "cxx", "hpp", "rs", "py", "pl", "pm", "t",
|
||||||
"sh", "bash", "zsh", "fish", "js", "mjs", "cjs", "ts", "jsx", "tsx",
|
"sh", "bash", "zsh", "fish", "js", "mjs", "cjs", "ts", "jsx", "tsx",
|
||||||
@@ -33,7 +33,7 @@ func init() {
|
|||||||
register(cOrange,
|
register(cOrange,
|
||||||
"zip", "tar", "gz", "tgz", "bz2", "tbz", "xz", "txz", "zst", "7z",
|
"zip", "tar", "gz", "tgz", "bz2", "tbz", "xz", "txz", "zst", "7z",
|
||||||
"rar", "lz", "lzma", "z", "jar", "war", "dmg", "iso", "pkg", "deb", "rpm")
|
"rar", "lz", "lzma", "z", "jar", "war", "dmg", "iso", "pkg", "deb", "rpm")
|
||||||
// image — violett
|
// image — violet
|
||||||
register(cViolet,
|
register(cViolet,
|
||||||
"jpg", "jpeg", "png", "gif", "bmp", "tif", "tiff", "svg", "webp",
|
"jpg", "jpeg", "png", "gif", "bmp", "tif", "tiff", "svg", "webp",
|
||||||
"heic", "heif", "ico", "psd", "ai", "eps", "raw", "cr2", "nef", "dng",
|
"heic", "heif", "ico", "psd", "ai", "eps", "raw", "cr2", "nef", "dng",
|
||||||
@@ -41,26 +41,26 @@ func init() {
|
|||||||
// video — pink
|
// video — pink
|
||||||
register(cPink,
|
register(cPink,
|
||||||
"mp4", "mov", "avi", "mkv", "webm", "m4v", "mpg", "mpeg", "wmv", "flv", "m2ts")
|
"mp4", "mov", "avi", "mkv", "webm", "m4v", "mpg", "mpeg", "wmv", "flv", "m2ts")
|
||||||
// music / lossless — grün
|
// music / lossless — green
|
||||||
register(cGreen,
|
register(cGreen,
|
||||||
"mp3", "aac", "m4a", "ogg", "opus", "wma", "flac", "wav", "aiff", "aif", "alac")
|
"mp3", "aac", "m4a", "ogg", "opus", "wma", "flac", "wav", "aiff", "aif", "alac")
|
||||||
// crypto — rot
|
// crypto — red
|
||||||
register(cRed,
|
register(cRed,
|
||||||
"asc", "gpg", "pgp", "sig", "key", "pem", "crt", "cer", "p12", "kdbx")
|
"asc", "gpg", "pgp", "sig", "key", "pem", "crt", "cer", "p12", "kdbx")
|
||||||
// document — weiß
|
// document — white
|
||||||
register(cWhite,
|
register(cWhite,
|
||||||
"pdf", "doc", "docx", "odt", "xls", "xlsx", "ods", "ppt", "pptx", "odp",
|
"pdf", "doc", "docx", "odt", "xls", "xlsx", "ods", "ppt", "pptx", "odp",
|
||||||
"epub", "rtf", "txt", "csv", "pages", "numbers")
|
"epub", "rtf", "txt", "csv", "pages", "numbers")
|
||||||
// compiled — grau
|
// compiled — grey
|
||||||
register(cGrey,
|
register(cGrey,
|
||||||
"o", "a", "so", "dylib", "obj", "lib", "class", "pyc", "pyo", "wasm", "d")
|
"o", "a", "so", "dylib", "obj", "lib", "class", "pyc", "pyo", "wasm", "d")
|
||||||
// temp — dunkel
|
// temp — dark
|
||||||
register(cDarker,
|
register(cDarker,
|
||||||
"tmp", "temp", "swp", "swo", "bak", "old", "orig", "rej", "lock",
|
"tmp", "temp", "swp", "swo", "bak", "old", "orig", "rej", "lock",
|
||||||
"part", "crdownload", "log")
|
"part", "crdownload", "log")
|
||||||
}
|
}
|
||||||
|
|
||||||
// build — gelb (README, Makefile, …)
|
// build — yellow (README, Makefile, …)
|
||||||
var byName = map[string]*color.Color{
|
var byName = map[string]*color.Color{
|
||||||
"makefile": cYellow, "gnumakefile": cYellow, "cmakelists.txt": cYellow,
|
"makefile": cYellow, "gnumakefile": cYellow, "cmakelists.txt": cYellow,
|
||||||
"dockerfile": cYellow, "justfile": cYellow, "rakefile": cYellow,
|
"dockerfile": cYellow, "justfile": cYellow, "rakefile": cYellow,
|
||||||
@@ -71,7 +71,7 @@ var byName = map[string]*color.Color{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fileColor(name string, mode os.FileMode) *color.Color {
|
func fileColor(name string, mode os.FileMode) *color.Color {
|
||||||
if mode&0o111 != 0 { // ausführbar schlägt alles andere, wie in eza
|
if mode&0o111 != 0 { // executable beats everything else, as in eza
|
||||||
return cExecB
|
return cExecB
|
||||||
}
|
}
|
||||||
low := strings.ToLower(name)
|
low := strings.ToLower(name)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// dx — count files and directories, mwx'2016
|
// dx — count files and directories, mwx'2016
|
||||||
//
|
//
|
||||||
// Go-Portierung des Perl-Originals (dx 1.0.2), Ausgabe eingefärbt nach der
|
// Go port of the Perl original (dx 1.0.2), output coloured after the mwxcol
|
||||||
// mwxcol-Palette (~/src/mwxcol). Ohne Terminal (Pipe, NO_COLOR, --color=never)
|
// palette (~/src/mwxcol). Without a terminal (pipe, NO_COLOR, --color=never)
|
||||||
// bleibt die Ausgabe unverändert einfarbig.
|
// the output stays plain, exactly as it was.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -22,9 +22,9 @@ import (
|
|||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version ist eine var (keine const), damit build.sh die gebaute Nummer per
|
// version is a var, not a const, so build.sh can put the built number in via
|
||||||
// -ldflags "-X main.version=…" einsetzen kann. Der Wert hier erscheint nur bei
|
// -ldflags "-X main.version=…". The value here only ever shows up in a bare
|
||||||
// einem nackten `go build`; die wirklich gebaute Version steht in version.txt.
|
// `go build`; the version actually built is the one in version.txt.
|
||||||
var version = "2.1.0"
|
var version = "2.1.0"
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Palette
|
// ------------------------------------------------------------------ Palette
|
||||||
@@ -48,7 +48,7 @@ var (
|
|||||||
func plain(c rgb) *color.Color { return color.RGB(c.r, c.g, c.b) }
|
func plain(c rgb) *color.Color { return color.RGB(c.r, c.g, c.b) }
|
||||||
func bold(c rgb) *color.Color { return color.New(color.Bold).AddRGB(c.r, c.g, c.b) }
|
func bold(c rgb) *color.Color { return color.New(color.Bold).AddRGB(c.r, c.g, c.b) }
|
||||||
|
|
||||||
// Rollen wie im eza-Theme, damit dx und eza dieselbe Datei gleich einfärben.
|
// The roles of the eza theme, so that dx and eza colour the same file alike.
|
||||||
var (
|
var (
|
||||||
cWhite = plain(white)
|
cWhite = plain(white)
|
||||||
cYellow = plain(yellow)
|
cYellow = plain(yellow)
|
||||||
@@ -61,8 +61,8 @@ var (
|
|||||||
cGrey = plain(grey)
|
cGrey = plain(grey)
|
||||||
cDark = plain(dark)
|
cDark = plain(dark)
|
||||||
cDarker = plain(darker)
|
cDarker = plain(darker)
|
||||||
cDirB = bold(blue) // Verzeichnis
|
cDirB = bold(blue) // directory
|
||||||
cExecB = bold(green) // ausführbar
|
cExecB = bold(green) // executable
|
||||||
cWhiteB = bold(white)
|
cWhiteB = bold(white)
|
||||||
cYellowB = bold(yellow)
|
cYellowB = bold(yellow)
|
||||||
cOrangeB = bold(orange)
|
cOrangeB = bold(orange)
|
||||||
@@ -71,14 +71,14 @@ var (
|
|||||||
cDarkB = bold(dark)
|
cDarkB = bold(dark)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ------------------------------------------------------------------- Zählen
|
// ----------------------------------------------------------------- Counting
|
||||||
|
|
||||||
type entry struct {
|
type entry struct {
|
||||||
name string // Anzeigename, Verzeichnisse mit "/"
|
name string // display name, directories with a "/"
|
||||||
col *color.Color
|
col *color.Color
|
||||||
n int64
|
n int64
|
||||||
size int64
|
size int64
|
||||||
agg bool // Sammelzeile aus -t, kein echter Eintrag
|
agg bool // the summary line from -t, not a real entry
|
||||||
}
|
}
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
@@ -103,7 +103,7 @@ func scan(dir string, onlyDirs bool) (result, error) {
|
|||||||
name := de.Name()
|
name := de.Name()
|
||||||
path := filepath.Join(dir, name)
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
// Perls -d/-f folgen Symlinks; ein toter Link fällt durch beide Tests.
|
// Perl's -d/-f follow symlinks; a dead link fails both tests.
|
||||||
st, err := os.Stat(path)
|
st, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
atomic.AddInt64(&res.unreadbl, 1)
|
atomic.AddInt64(&res.unreadbl, 1)
|
||||||
@@ -117,7 +117,7 @@ func scan(dir string, onlyDirs bool) (result, error) {
|
|||||||
sem <- struct{}{}
|
sem <- struct{}{}
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
|
|
||||||
// WalkDir folgt Symlinks nicht — wie `find <pfad> -type f`.
|
// WalkDir does not follow symlinks — like `find <path> -type f`.
|
||||||
n, size := walk(path, &res.unreadbl)
|
n, size := walk(path, &res.unreadbl)
|
||||||
|
|
||||||
col := cDirB
|
col := cDirB
|
||||||
@@ -154,7 +154,7 @@ func walk(root string, unreadable *int64) (n, size int64) {
|
|||||||
atomic.AddInt64(unreadable, 1)
|
atomic.AddInt64(unreadable, 1)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !d.Type().IsRegular() { // wie find -type f
|
if !d.Type().IsRegular() { // like find -type f
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
info, err := d.Info()
|
info, err := d.Info()
|
||||||
@@ -169,10 +169,10 @@ func walk(root string, unreadable *int64) (n, size int64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Ausgabe
|
// ------------------------------------------------------------------- Output
|
||||||
|
|
||||||
// Schwellen wie im Perl-Original; unterhalb von 10 der jeweiligen Einheit
|
// The thresholds of the Perl original; below 10 of the unit in question,
|
||||||
// jedoch mit einer Nachkommastelle — "1.9G" statt "2G".
|
// though, with one decimal — "1.9G" instead of "2G".
|
||||||
func formSize(b int64) string {
|
func formSize(b int64) string {
|
||||||
const k = 1024
|
const k = 1024
|
||||||
f := float64(b)
|
f := float64(b)
|
||||||
@@ -186,7 +186,7 @@ func formSize(b int64) string {
|
|||||||
case b > k:
|
case b > k:
|
||||||
return scaled(f/k, "K")
|
return scaled(f/k, "K")
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("%.0f", f) // ganze Bytes, nie gebrochen
|
return fmt.Sprintf("%.0f", f) // whole bytes, never fractions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +197,8 @@ func scaled(v float64, unit string) string {
|
|||||||
return fmt.Sprintf("%.0f%s", v, unit)
|
return fmt.Sprintf("%.0f%s", v, unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Größenrampe wie im eza-Theme: byte/kilo grün, mega gelb, giga orange, huge rot.
|
// The size ramp of the eza theme: byte/kilo green, mega yellow, giga orange,
|
||||||
|
// huge red.
|
||||||
func sizeColor(b int64, strong bool) *color.Color {
|
func sizeColor(b int64, strong bool) *color.Color {
|
||||||
switch {
|
switch {
|
||||||
case b == 0:
|
case b == 0:
|
||||||
@@ -242,7 +243,7 @@ func pad(s string, w int) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anteilsbalken in Achtelschritten, feste Breite barCells.
|
// Share bar in eighths of a cell, barCells wide throughout.
|
||||||
const barCells = 10
|
const barCells = 10
|
||||||
|
|
||||||
var eighths = []rune{' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'}
|
var eighths = []rune{' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'}
|
||||||
@@ -251,7 +252,7 @@ func bar(frac float64) string {
|
|||||||
if frac > 1 {
|
if frac > 1 {
|
||||||
frac = 1
|
frac = 1
|
||||||
}
|
}
|
||||||
if !(frac > 0) { // fängt auch NaN
|
if !(frac > 0) { // catches NaN too
|
||||||
return strings.Repeat(" ", barCells)
|
return strings.Repeat(" ", barCells)
|
||||||
}
|
}
|
||||||
units := int(frac*barCells*8 + 0.5)
|
units := int(frac*barCells*8 + 0.5)
|
||||||
@@ -288,10 +289,10 @@ func printDir(w *bufio.Writer, dir string, res result, o opts) {
|
|||||||
if ka, kb := metric(a), metric(b); ka != kb {
|
if ka, kb := metric(a), metric(b); ka != kb {
|
||||||
return ka < kb
|
return ka < kb
|
||||||
}
|
}
|
||||||
return a.name < b.name // Perl ließ Gleichstände der Hash-Ordnung
|
return a.name < b.name // Perl left ties to the order of the hash
|
||||||
})
|
})
|
||||||
|
|
||||||
// -t N: nur die N größten zeigen, der Rest wird zu einer Zeile.
|
// -t N: show only the N largest, the rest becomes a single line.
|
||||||
rows := res.entries
|
rows := res.entries
|
||||||
var rest *entry
|
var rest *entry
|
||||||
if o.top > 0 && len(rows) > o.top {
|
if o.top > 0 && len(rows) > o.top {
|
||||||
@@ -310,7 +311,7 @@ func printDir(w *bufio.Writer, dir string, res result, o opts) {
|
|||||||
rows[i], rows[j] = rows[j], rows[i]
|
rows[i], rows[j] = rows[j], rows[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if rest != nil { // Restzeile bleibt am kleinen Ende
|
if rest != nil { // the summary line stays at the small end
|
||||||
if o.reverse {
|
if o.reverse {
|
||||||
rows = append(rows, *rest)
|
rows = append(rows, *rest)
|
||||||
} else {
|
} else {
|
||||||
@@ -347,12 +348,12 @@ func printDir(w *bufio.Writer, dir string, res result, o opts) {
|
|||||||
if total > 0 {
|
if total > 0 {
|
||||||
frac = float64(metric(e)) / float64(total)
|
frac = float64(metric(e)) / float64(total)
|
||||||
}
|
}
|
||||||
// Balken trägt die Farbe der Spalte, deren Anteil er zeigt.
|
// The bar takes the colour of the column whose share it shows.
|
||||||
col := sizeColor(e.size, false)
|
col := sizeColor(e.size, false)
|
||||||
if o.byCount {
|
if o.byCount {
|
||||||
col = countColor(e.n)
|
col = countColor(e.n)
|
||||||
}
|
}
|
||||||
if e.agg { // Sammelzeile bleibt zurückhaltend
|
if e.agg { // the summary line keeps quiet
|
||||||
col = cDark
|
col = cDark
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " %s %s",
|
fmt.Fprintf(w, " %s %s",
|
||||||
@@ -368,7 +369,7 @@ type opts struct {
|
|||||||
byCount bool // -n
|
byCount bool // -n
|
||||||
onlyDirs bool // -d
|
onlyDirs bool // -d
|
||||||
reverse bool // -r
|
reverse bool // -r
|
||||||
top int // -t N, 0 = alle
|
top int // -t N, 0 = all
|
||||||
bar bool
|
bar bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +386,9 @@ usage: dx [-n] [-d] [-r] [-t N] [path]
|
|||||||
--version
|
--version
|
||||||
--check-update look for a newer release
|
--check-update look for a newer release
|
||||||
--update download and install the newest release
|
--update download and install the newest release
|
||||||
|
|
||||||
|
dx looks for a new release once a day, in the background, and says so on
|
||||||
|
stderr. DX_NO_UPDATE_CHECK=1 turns that off.
|
||||||
`, version)
|
`, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +399,7 @@ func die(format string, a ...any) {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var o opts
|
var o opts
|
||||||
var barSet bool // sonst folgt der Balken der Farbentscheidung
|
var barSet bool // otherwise the bar follows the colour decision
|
||||||
var dir string
|
var dir string
|
||||||
when := "auto"
|
when := "auto"
|
||||||
|
|
||||||
@@ -426,6 +430,9 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
case a == updateRefreshFlag: // the background run, not in the help
|
||||||
|
selfUpdate.refresh()
|
||||||
|
return
|
||||||
case a == "--number":
|
case a == "--number":
|
||||||
o.byCount = true
|
o.byCount = true
|
||||||
case a == "--dirs":
|
case a == "--dirs":
|
||||||
@@ -444,7 +451,7 @@ func main() {
|
|||||||
when = strings.TrimPrefix(a, "--color=")
|
when = strings.TrimPrefix(a, "--color=")
|
||||||
case len(a) > 1 && a[0] == '-':
|
case len(a) > 1 && a[0] == '-':
|
||||||
flags := a[1:]
|
flags := a[1:]
|
||||||
for j := 0; j < len(flags); j++ { // Bündelung wie Getopt::Std: -dn
|
for j := 0; j < len(flags); j++ { // bundling as in Getopt::Std: -dn
|
||||||
switch flags[j] {
|
switch flags[j] {
|
||||||
case 'n':
|
case 'n':
|
||||||
o.byCount = true
|
o.byCount = true
|
||||||
@@ -455,7 +462,7 @@ func main() {
|
|||||||
case 'h':
|
case 'h':
|
||||||
usage(os.Stdout)
|
usage(os.Stdout)
|
||||||
return
|
return
|
||||||
case 't': // Wert klebt am Flag (-t5) oder folgt (-t 5)
|
case 't': // the value sticks to the flag (-t5) or follows (-t 5)
|
||||||
val := flags[j+1:]
|
val := flags[j+1:]
|
||||||
if val == "" {
|
if val == "" {
|
||||||
if i+1 >= len(args) {
|
if i+1 >= len(args) {
|
||||||
@@ -482,7 +489,7 @@ func main() {
|
|||||||
color.NoColor = false
|
color.NoColor = false
|
||||||
case "never":
|
case "never":
|
||||||
color.NoColor = true
|
color.NoColor = true
|
||||||
case "auto": // fatih/color entscheidet per isatty und NO_COLOR
|
case "auto": // fatih/color decides, by isatty and NO_COLOR
|
||||||
default:
|
default:
|
||||||
die("--color: expected auto|always|never, got %q", when)
|
die("--color: expected auto|always|never, got %q", when)
|
||||||
}
|
}
|
||||||
@@ -507,6 +514,11 @@ func main() {
|
|||||||
if res.unreadbl > 0 {
|
if res.unreadbl > 0 {
|
||||||
fmt.Fprintln(os.Stderr, cDark.Sprintf("dx: %d unreadable entries skipped", res.unreadbl))
|
fmt.Fprintln(os.Stderr, cDark.Sprintf("dx: %d unreadable entries skipped", res.unreadbl))
|
||||||
}
|
}
|
||||||
|
// Costs nothing: the hint comes from the note in the cache, and the asking
|
||||||
|
// happens once a day at most, in the background.
|
||||||
|
if hint := selfUpdate.daily(); hint != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, cDark.Sprint(hint))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustNum(s string) int {
|
func mustNum(s string) int {
|
||||||
|
|||||||
+193
-58
@@ -1,17 +1,17 @@
|
|||||||
// selfupdate.go — Selbstaktualisierung aus den Releases einer Gitea-Instanz.
|
// selfupdate.go — updating oneself from the releases of a Gitea instance.
|
||||||
//
|
//
|
||||||
// Die Datei ist als Kopiervorlage gedacht: in ein anderes Programm übernehmen,
|
// The file is meant to be copied: take it into another program, adjust the
|
||||||
// den Konfigurationsblock unten anpassen, `--update` und `--check-update` in
|
// configuration block below, hang `--update` and `--check-update` into the
|
||||||
// die Optionen hängen — fertig. Sie braucht nur die Standardbibliothek und
|
// options — done. It needs nothing but the standard library, and apart from
|
||||||
// bringt außer dem Block keine Namen mit, die nicht mit "selfUpdate" oder
|
// that block it brings no names that do not begin with "selfUpdate" or
|
||||||
// "update" beginnen.
|
// "update".
|
||||||
//
|
//
|
||||||
// Vorausgesetzt wird das Ablageschema von build.sh: je Version ein Release,
|
// It assumes the layout build.sh produces: one release per version, whose tag
|
||||||
// dessen Tag die nackte Nummer ist (2.1.6, ein führendes "v" ist erlaubt), und
|
// is the bare number (2.1.6, a leading "v" is allowed), holding one asset
|
||||||
// darin je ein Asset "<name>-<goos>-<goarch>" — also genau die Dateien aus
|
// "<name>-<goos>-<goarch>" each — that is, exactly the files from ./bin. Under
|
||||||
// ./bin. Gitea liefert unter /api/v1/repos/<owner>/<repo>/releases/latest das
|
// /api/v1/repos/<owner>/<repo>/releases/latest Gitea hands out the newest
|
||||||
// neueste Release ohne Entwurf und ohne Vorabversion; GitHub spricht dieselbe
|
// release that is neither a draft nor a prerelease; GitHub speaks the same
|
||||||
// Route mit anderen Feldnamen und ist deshalb nicht mitgemeint.
|
// route with different field names and is therefore not covered.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -31,25 +31,151 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ------------------------------------------------------------ Konfiguration
|
// ------------------------------------------------------------ Configuration
|
||||||
|
|
||||||
var selfUpdate = selfUpdater{
|
var selfUpdate = selfUpdater{
|
||||||
repo: "https://git.micw.org/mike/dx",
|
repo: "https://git.micw.org/mike/dx",
|
||||||
asset: "dx",
|
asset: "dx",
|
||||||
current: version, // aus main.go, per -ldflags gesetzt
|
current: version, // from main.go, set by -ldflags
|
||||||
verify: []string{"--version"},
|
verify: []string{"--version"},
|
||||||
|
every: 24 * time.Hour,
|
||||||
|
quietEnv: "DX_NO_UPDATE_CHECK",
|
||||||
}
|
}
|
||||||
|
|
||||||
type selfUpdater struct {
|
type selfUpdater struct {
|
||||||
repo string // Repo-URL wie im Browser: https://host/owner/repo
|
repo string // repo URL as in the browser: https://host/owner/repo
|
||||||
asset string // Basisname der Assets, "-<goos>-<goarch>" kommt dazu
|
asset string // base name of the assets, "-<goos>-<goarch>" is added
|
||||||
current string // laufende Version
|
current string // the running version
|
||||||
verify []string // Probelauf des Downloads; leer lässt ihn ausfallen
|
verify []string // trial run of the download; empty skips it
|
||||||
|
every time.Duration // how often to look on its own; 0 turns that off
|
||||||
|
quietEnv string // this environment variable set: keep quiet as well
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Ablauf
|
// updateRefreshFlag is the option the program calls itself with, in the
|
||||||
|
// background. It is deliberately absent from the help.
|
||||||
|
const updateRefreshFlag = "--update-refresh"
|
||||||
|
|
||||||
// check sieht nur nach und fasst nichts an.
|
// ------------------------------------------------------------ Looking by itself
|
||||||
|
|
||||||
|
// daily is the hook for the ordinary run of the program. It costs nothing: in
|
||||||
|
// the foreground the network is never touched. What comes back is the line
|
||||||
|
// pointing at a new version — or "", when there is nothing to say; what it
|
||||||
|
// looks like is up to the caller. Should the note be older than `every`, daily
|
||||||
|
// starts a background run on the side, whose answer the next call will find
|
||||||
|
// waiting.
|
||||||
|
func (u selfUpdater) daily() string {
|
||||||
|
if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !updateOnTerminal() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
st := u.loadState() // no file: the zero value, hence due at once
|
||||||
|
|
||||||
|
if time.Since(st.Checked) >= u.every {
|
||||||
|
// The timestamp moves on before the asking, not after: otherwise two
|
||||||
|
// simultaneous runs start two queries, and a server that is not in the
|
||||||
|
// mood would get a new one on every call. If the note does not stay
|
||||||
|
// put, nothing is asked either — else an unwritable cache directory
|
||||||
|
// would mean one process per call.
|
||||||
|
st.Checked = time.Now()
|
||||||
|
if u.saveState(st) == nil {
|
||||||
|
u.spawnRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.Latest == "" || updateCompare(st.Latest, u.current) <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh is the background run: ask, write it down, stay quiet. The writing
|
||||||
|
// down is done by latest; if the query fails, the old state remains.
|
||||||
|
func (u selfUpdater) refresh() {
|
||||||
|
_, _ = u.latest()
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnRefresh calls this program once more, only to ask, and does not wait.
|
||||||
|
// Without a Wait the child is adopted by init when this process ends — it thus
|
||||||
|
// outlives the call, and the call's output stays untouched by it.
|
||||||
|
func (u selfUpdater) spawnRefresh() {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd := exec.Command(exe, updateRefreshFlag)
|
||||||
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null
|
||||||
|
if cmd.Start() == nil {
|
||||||
|
cmd.Process.Release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hint is meant for the person sitting there. Running in a pipe, in a
|
||||||
|
// script or under cron, the program neither asks nor says anything.
|
||||||
|
func updateOnTerminal() bool {
|
||||||
|
st, err := os.Stderr.Stat()
|
||||||
|
return err == nil && st.Mode()&os.ModeCharDevice != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- Note
|
||||||
|
|
||||||
|
// updateState is what is left between two calls: when the last question was
|
||||||
|
// asked and what came of it.
|
||||||
|
type updateState struct {
|
||||||
|
Checked time.Time `json:"checked"`
|
||||||
|
Latest string `json:"latest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The note lives in the cache directory, not in the configuration: if it gets
|
||||||
|
// lost, the only cost is asking once too early.
|
||||||
|
func (u selfUpdater) statePath() (string, error) {
|
||||||
|
dir, err := os.UserCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, u.asset, "update.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u selfUpdater) loadState() updateState {
|
||||||
|
var st updateState
|
||||||
|
path, err := u.statePath()
|
||||||
|
if err != nil {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
json.Unmarshal(b, &st) // a broken file counts as none
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u selfUpdater) saveState(st updateState) error {
|
||||||
|
path, err := u.statePath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// By way of a file alongside, so that a simultaneous run never comes upon
|
||||||
|
// half a JSON.
|
||||||
|
tmp := path + ".new"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ The work
|
||||||
|
|
||||||
|
// check only looks and touches nothing.
|
||||||
func (u selfUpdater) check(w io.Writer) error {
|
func (u selfUpdater) check(w io.Writer) error {
|
||||||
rel, err := u.latest()
|
rel, err := u.latest()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -64,7 +190,7 @@ func (u selfUpdater) check(w io.Writer) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// install holt das neueste Release und ersetzt die laufende Datei damit.
|
// install fetches the newest release and replaces the running file with it.
|
||||||
func (u selfUpdater) install(w io.Writer) error {
|
func (u selfUpdater) install(w io.Writer) error {
|
||||||
rel, err := u.latest()
|
rel, err := u.latest()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -95,8 +221,8 @@ func (u selfUpdater) install(w io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot locate the running binary: %w", err)
|
return fmt.Errorf("cannot locate the running binary: %w", err)
|
||||||
}
|
}
|
||||||
// Ein installiertes dx ist oft ein Symlink nach ./bin. Ersetzt werden soll
|
// An installed dx is often a symlink into ./bin. What should be replaced is
|
||||||
// die Datei dahinter, nicht der Link.
|
// the file behind it, not the link.
|
||||||
if real, err := filepath.EvalSymlinks(exe); err == nil {
|
if real, err := filepath.EvalSymlinks(exe); err == nil {
|
||||||
exe = real
|
exe = real
|
||||||
}
|
}
|
||||||
@@ -110,7 +236,7 @@ func (u selfUpdater) install(w io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.Remove(tmp) // greift nur, wenn das Umbenennen unten ausfällt
|
defer os.Remove(tmp) // only bites when the renaming below falls through
|
||||||
|
|
||||||
if err := u.probe(tmp, rel.TagName); err != nil {
|
if err := u.probe(tmp, rel.TagName); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -124,14 +250,14 @@ func (u selfUpdater) install(w io.Writer) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (string, error) {
|
func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (string, error) {
|
||||||
// Die neue Datei entsteht neben der alten: dasselbe Dateisystem, also ist
|
// The new file comes into being next to the old one: same filesystem, so
|
||||||
// das Umbenennen am Ende ein atomarer Schritt und kein halber Kopiervorgang.
|
// the renaming at the end is one atomic step and not half a copy. It also
|
||||||
// Sie entsteht auch vor dem ersten Byte — ein fehlendes Schreibrecht soll
|
// comes into being before the first byte — a missing write permission ought
|
||||||
// auffallen, bevor ein paar Megabyte durch die Leitung sind.
|
// to show up before a few megabytes have gone down the wire.
|
||||||
dir := filepath.Dir(exe)
|
dir := filepath.Dir(exe)
|
||||||
f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new")
|
f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var pe *os.PathError // der Pfad steht schon in der Meldung
|
var pe *os.PathError // the path is in the message already
|
||||||
if errors.As(err, &pe) {
|
if errors.As(err, &pe) {
|
||||||
err = pe.Err
|
err = pe.Err
|
||||||
}
|
}
|
||||||
@@ -139,7 +265,7 @@ func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (str
|
|||||||
}
|
}
|
||||||
tmp := f.Name()
|
tmp := f.Name()
|
||||||
|
|
||||||
resp, err := updateGet(a.URL)
|
resp, err := updateGet(context.Background(), a.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
@@ -164,9 +290,9 @@ func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (str
|
|||||||
return tmp, nil
|
return tmp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// probe ruft den frisch geladenen Läufer einmal auf. Das fängt eine
|
// probe calls the freshly fetched runner once. That catches a file that is
|
||||||
// abgeschnittene, für die falsche Plattform gebaute oder gar nicht erst
|
// truncated, built for the wrong platform, or not executable in the first
|
||||||
// ausführbare Datei ab, bevor sie die laufende ersetzt.
|
// place, before it replaces the running one.
|
||||||
func (u selfUpdater) probe(path, tag string) error {
|
func (u selfUpdater) probe(path, tag string) error {
|
||||||
if len(u.verify) == 0 {
|
if len(u.verify) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -185,21 +311,22 @@ func (u selfUpdater) probe(path, tag string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateReplace tauscht die laufende Datei gegen die neue.
|
// updateReplace swaps the running file for the new one.
|
||||||
func updateReplace(tmp, exe string) error {
|
func updateReplace(tmp, exe string) error {
|
||||||
if err := os.Rename(tmp, exe); err == nil {
|
if err := os.Rename(tmp, exe); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Unix überschreibt die Datei eines laufenden Programms klaglos, Windows
|
// Unix overwrites the file of a running program without complaint, Windows
|
||||||
// nicht: dort muss die alte erst aus dem Weg. Löschen lässt sie sich
|
// does not: there the old one has to be got out of the way first. Deleting
|
||||||
// frühestens, wenn dieser Prozess endet — das Aufräumen darf also scheitern.
|
// it becomes possible when this process ends at the earliest — so the
|
||||||
|
// tidying up is allowed to fail.
|
||||||
old := exe + ".old"
|
old := exe + ".old"
|
||||||
os.Remove(old)
|
os.Remove(old)
|
||||||
if err := os.Rename(exe, old); err != nil {
|
if err := os.Rename(exe, old); err != nil {
|
||||||
return fmt.Errorf("cannot replace %s: %w", exe, err)
|
return fmt.Errorf("cannot replace %s: %w", exe, err)
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmp, exe); err != nil {
|
if err := os.Rename(tmp, exe); err != nil {
|
||||||
os.Rename(old, exe) // zurück auf den alten Stand
|
os.Rename(old, exe) // back to how it was
|
||||||
return fmt.Errorf("cannot replace %s: %w", exe, err)
|
return fmt.Errorf("cannot replace %s: %w", exe, err)
|
||||||
}
|
}
|
||||||
os.Remove(old)
|
os.Remove(old)
|
||||||
@@ -225,7 +352,12 @@ func (u selfUpdater) latest() (updateRelease, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return updateRelease{}, err
|
return updateRelease{}, err
|
||||||
}
|
}
|
||||||
resp, err := updateGet(base + "/releases/latest")
|
// The question is a small one; if it hangs, it does not hang for long. The
|
||||||
|
// generous time limit of updateClient is meant for the download.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := updateGet(ctx, base+"/releases/latest")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return updateRelease{}, err
|
return updateRelease{}, err
|
||||||
}
|
}
|
||||||
@@ -238,10 +370,14 @@ func (u selfUpdater) latest() (updateRelease, error) {
|
|||||||
if rel.TagName == "" {
|
if rel.TagName == "" {
|
||||||
return updateRelease{}, fmt.Errorf("%s has no releases", u.repo)
|
return updateRelease{}, fmt.Errorf("%s has no releases", u.repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every question that succeeds fills the note — no matter whether it came
|
||||||
|
// from --update, from --check-update or from the background run.
|
||||||
|
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
|
||||||
return rel, nil
|
return rel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiBase macht aus https://host/owner/repo die API-Wurzel des Repos.
|
// apiBase turns https://host/owner/repo into the API root of the repo.
|
||||||
func (u selfUpdater) apiBase() (string, error) {
|
func (u selfUpdater) apiBase() (string, error) {
|
||||||
bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo)
|
bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo)
|
||||||
|
|
||||||
@@ -256,13 +392,12 @@ func (u selfUpdater) apiBase() (string, error) {
|
|||||||
return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil
|
return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Zeitlimit für alles zusammen: die Suche kostet ein paar hundert
|
// One time limit for all of it: the look costs a few hundred milliseconds, the
|
||||||
// Millisekunden, der Download ein paar Megabyte — beides darf hängen bleiben,
|
// download a few megabytes — both may hang, but not forever.
|
||||||
// aber nicht ewig.
|
|
||||||
var updateClient = &http.Client{Timeout: 5 * time.Minute}
|
var updateClient = &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
|
||||||
func updateGet(target string) (*http.Response, error) {
|
func updateGet(ctx context.Context, target string) (*http.Response, error) {
|
||||||
req, err := http.NewRequest(http.MethodGet, target, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -279,13 +414,13 @@ func updateGet(target string) (*http.Response, error) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Nummern
|
// ------------------------------------------------------------------ Numbers
|
||||||
|
|
||||||
// updateCompare vergleicht zwei Versionen komponentenweise numerisch, damit
|
// updateCompare compares two versions component by component, numerically, so
|
||||||
// 2.1.10 hinter 2.1.9 landet und nicht davor. Ein führendes "v" zählt nicht,
|
// that 2.1.10 lands behind 2.1.9 and not in front of it. A leading "v" does not
|
||||||
// fehlende Stellen gelten als 0 (2.1 == 2.1.0), und ein Suffix am Zahlenrest
|
// count, missing places count as 0 (2.1 == 2.1.0), and a suffix on the number
|
||||||
// macht die Version älter, nicht neuer (2.1.6-rc1 < 2.1.6). Ergebnis wie bei
|
// makes the version older, not newer (2.1.6-rc1 < 2.1.6). The result is the one
|
||||||
// strings.Compare: -1, 0, 1.
|
// of strings.Compare: -1, 0, 1.
|
||||||
func updateCompare(a, b string) int {
|
func updateCompare(a, b string) int {
|
||||||
as := strings.Split(strings.TrimPrefix(a, "v"), ".")
|
as := strings.Split(strings.TrimPrefix(a, "v"), ".")
|
||||||
bs := strings.Split(strings.TrimPrefix(b, "v"), ".")
|
bs := strings.Split(strings.TrimPrefix(b, "v"), ".")
|
||||||
@@ -316,7 +451,7 @@ func updateComparePart(a, b string) int {
|
|||||||
return 1
|
return 1
|
||||||
case ra == rb:
|
case ra == rb:
|
||||||
return 0
|
return 0
|
||||||
case ra == "": // 2.1.6 ist fertig, 2.1.6-rc1 noch nicht
|
case ra == "": // 2.1.6 is finished, 2.1.6-rc1 is not yet
|
||||||
return 1
|
return 1
|
||||||
case rb == "":
|
case rb == "":
|
||||||
return -1
|
return -1
|
||||||
@@ -324,7 +459,7 @@ func updateComparePart(a, b string) int {
|
|||||||
return strings.Compare(ra, rb)
|
return strings.Compare(ra, rb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSplitNum trennt "10-rc1" in 10 und "-rc1".
|
// updateSplitNum separates "10-rc1" into 10 and "-rc1".
|
||||||
func updateSplitNum(s string) (int, string) {
|
func updateSplitNum(s string) (int, string) {
|
||||||
i := 0
|
i := 0
|
||||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||||
@@ -334,8 +469,8 @@ func updateSplitNum(s string) (int, string) {
|
|||||||
return n, s[i:]
|
return n, s[i:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSize ist bewusst eine eigene kleine Formatierung und nicht formSize aus
|
// updateSize is deliberately a small formatting of its own and not formSize
|
||||||
// main.go — die Datei soll für sich alleine stehen.
|
// from main.go — the file is meant to stand on its own.
|
||||||
func updateSize(b int64) string {
|
func updateSize(b int64) string {
|
||||||
const k = 1024
|
const k = 1024
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.1.8
|
2.1.10
|
||||||
|
|||||||
Reference in New Issue
Block a user