rakpak 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/bin/rakpak +10 -0
- data/lib/rakpak/app.rb +662 -0
- data/lib/rakpak/browser.rb +508 -0
- data/lib/rakpak/dir_cache.rb +38 -0
- data/lib/rakpak/entry.rb +78 -0
- data/lib/rakpak/formats.rb +190 -0
- data/lib/rakpak/job.rb +264 -0
- data/lib/rakpak/modal.rb +562 -0
- data/lib/rakpak/plan.rb +281 -0
- data/lib/rakpak/screen.rb +135 -0
- data/lib/rakpak/sizer.rb +114 -0
- data/lib/rakpak/term.rb +147 -0
- data/lib/rakpak/text.rb +142 -0
- data/lib/rakpak/theme.rb +32 -0
- data/lib/rakpak/version.rb +5 -0
- data/lib/rakpak.rb +100 -0
- metadata +66 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rakpak
|
|
4
|
+
# What this machine can actually do. Nothing here is assumed; every codec
|
|
5
|
+
# is probed against PATH so the UI can grey out what is missing instead of
|
|
6
|
+
# failing halfway through a job.
|
|
7
|
+
module Tools
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def which(bin)
|
|
11
|
+
return @which[bin] if defined?(@which) && @which&.key?(bin)
|
|
12
|
+
|
|
13
|
+
@which ||= {}
|
|
14
|
+
@which[bin] = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).lazy
|
|
15
|
+
.map { |d| File.join(d, bin) }
|
|
16
|
+
.find { |p| File.file?(p) && File.executable?(p) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def available?(bin) = !which(bin).nil?
|
|
20
|
+
|
|
21
|
+
# Info-ZIP reports its compiled-in methods in `zip -v`.
|
|
22
|
+
def zip_has_bzip2?
|
|
23
|
+
return @zip_bz2 if defined?(@zip_bz2)
|
|
24
|
+
|
|
25
|
+
@zip_bz2 = available?("zip") && `zip -v 2>/dev/null`.include?("BZIP2_SUPPORT")
|
|
26
|
+
rescue StandardError
|
|
27
|
+
@zip_bz2 = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# GNU tar, bsdtar (macOS) and busybox tar disagree about flags. Detect
|
|
31
|
+
# once so the UI only offers switches this tar actually understands.
|
|
32
|
+
def tar_flavor
|
|
33
|
+
return @tar_flavor if defined?(@tar_flavor)
|
|
34
|
+
|
|
35
|
+
@tar_flavor =
|
|
36
|
+
if !available?("tar") then :missing
|
|
37
|
+
else
|
|
38
|
+
v = begin
|
|
39
|
+
`tar --version 2>/dev/null`
|
|
40
|
+
rescue StandardError
|
|
41
|
+
""
|
|
42
|
+
end
|
|
43
|
+
case v
|
|
44
|
+
when /GNU tar/ then :gnu
|
|
45
|
+
when /bsdtar|libarchive/ then :bsd
|
|
46
|
+
when /busybox/i then :busybox
|
|
47
|
+
else v.empty? ? :busybox : :unknown
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Long form works on GNU tar and bsdtar; -I is GNU-only.
|
|
53
|
+
def tar_pipes? = %i[gnu bsd unknown].include?(tar_flavor)
|
|
54
|
+
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A tar compression backend.
|
|
58
|
+
Codec = Struct.new(:id, :label, :ext, :bin, :levels, :default, :threads, :blurb,
|
|
59
|
+
:level_opt, keyword_init: true) do
|
|
60
|
+
def available? = bin.nil? || Tools.available?(bin)
|
|
61
|
+
|
|
62
|
+
def why_not = available? ? nil : "#{bin} not installed"
|
|
63
|
+
def container? = false
|
|
64
|
+
|
|
65
|
+
# ".tar.gz" for a tarball, ".gz" when compressing a lone file.
|
|
66
|
+
def single_ext = ext.delete_prefix(".tar")
|
|
67
|
+
|
|
68
|
+
# The compressor reading a named file and writing to stdout.
|
|
69
|
+
def argv(level)
|
|
70
|
+
return nil if bin.nil?
|
|
71
|
+
|
|
72
|
+
parts = [bin, "-c"]
|
|
73
|
+
# brotli's short -N form only accepts 0-9; 10 and 11 need -q N.
|
|
74
|
+
parts.concat(level_opt ? [level_opt, level.to_s] : ["-#{level}"]) if levels && level
|
|
75
|
+
parts.concat(threads) if threads
|
|
76
|
+
parts
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# tar's --use-compress-program string; tar splits it on spaces itself.
|
|
80
|
+
# The long form is understood by both GNU tar and bsdtar.
|
|
81
|
+
def filter(level) = argv(level)&.join(" ")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
TAR_CODECS = [
|
|
85
|
+
Codec.new(id: :none, label: "none", ext: ".tar", bin: nil,
|
|
86
|
+
levels: nil, default: nil, blurb: "no compression, fastest"),
|
|
87
|
+
Codec.new(id: :gzip, label: "gzip", ext: ".tar.gz", bin: "gzip",
|
|
88
|
+
levels: 1..9, default: 6, blurb: "universal, safe default"),
|
|
89
|
+
Codec.new(id: :zstd, label: "zstd", ext: ".tar.zst", bin: "zstd",
|
|
90
|
+
levels: 1..19, default: 3, threads: ["-T0"],
|
|
91
|
+
blurb: "best speed/ratio balance"),
|
|
92
|
+
Codec.new(id: :xz, label: "xz", ext: ".tar.xz", bin: "xz",
|
|
93
|
+
levels: 0..9, default: 6, threads: ["-T0"],
|
|
94
|
+
blurb: "smallest output, slow"),
|
|
95
|
+
Codec.new(id: :bzip2, label: "bzip2", ext: ".tar.bz2", bin: "bzip2",
|
|
96
|
+
levels: 1..9, default: 9, blurb: "legacy, slow"),
|
|
97
|
+
Codec.new(id: :lz4, label: "lz4", ext: ".tar.lz4", bin: "lz4",
|
|
98
|
+
levels: 1..12, default: 1, blurb: "extremely fast, low ratio"),
|
|
99
|
+
Codec.new(id: :brotli, label: "brotli", ext: ".tar.br", bin: "brotli",
|
|
100
|
+
levels: 0..11, default: 11, level_opt: "-q", blurb: "great on text")
|
|
101
|
+
].freeze
|
|
102
|
+
|
|
103
|
+
def self.tar_codec(id) = TAR_CODECS.find { |c| c.id == id }
|
|
104
|
+
|
|
105
|
+
# A zip container written by Info-ZIP, with one of its entry methods.
|
|
106
|
+
ZipMethod = Struct.new(:id, :label, :flag, :levels, :default, :blurb, keyword_init: true) do
|
|
107
|
+
def available?
|
|
108
|
+
flag == "bzip2" ? Tools.zip_has_bzip2? : Tools.available?("zip")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def why_not
|
|
112
|
+
return nil if available?
|
|
113
|
+
return "zip not installed" unless Tools.available?("zip")
|
|
114
|
+
|
|
115
|
+
"this zip lacks BZIP2_SUPPORT"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def container? = true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
ZIP_METHODS = [
|
|
122
|
+
ZipMethod.new(id: :zip, label: "zip", flag: "deflate", levels: 0..9, default: 6,
|
|
123
|
+
blurb: ".zip with deflate; reads everywhere"),
|
|
124
|
+
ZipMethod.new(id: :zip_bzip2, label: "zip (bzip2)", flag: "bzip2", levels: 1..9, default: 9,
|
|
125
|
+
blurb: "smaller .zip, needs a modern unzip"),
|
|
126
|
+
ZipMethod.new(id: :zip_store, label: "zip (store)", flag: "store", levels: nil, default: nil,
|
|
127
|
+
blurb: ".zip with no compression")
|
|
128
|
+
].freeze
|
|
129
|
+
|
|
130
|
+
# Everything the zip target can produce: a .zip of anything, or a lone
|
|
131
|
+
# file run through one compressor (notes.txt.gz).
|
|
132
|
+
COMPRESSORS = (ZIP_METHODS + TAR_CODECS.reject { |c| c.id == :none }).freeze
|
|
133
|
+
|
|
134
|
+
def self.compressor(id) = COMPRESSORS.find { |c| c.id == id }
|
|
135
|
+
|
|
136
|
+
# Toggleable switches, rendered as a checklist.
|
|
137
|
+
Flag = Struct.new(:id, :label, :args, :on, :blurb, :needs, keyword_init: true) do
|
|
138
|
+
# `needs` lists the tar flavours that understand this switch.
|
|
139
|
+
def supported?(flavor) = needs.nil? || needs.include?(flavor)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
GNU = %i[gnu unknown].freeze
|
|
143
|
+
BOTH = %i[gnu bsd unknown].freeze
|
|
144
|
+
ALL = %i[gnu bsd busybox unknown].freeze
|
|
145
|
+
|
|
146
|
+
# Only flags this machine's tar understands are offered.
|
|
147
|
+
def self.tar_flags(flavor = Tools.tar_flavor)
|
|
148
|
+
[
|
|
149
|
+
Flag.new(id: :verbose, label: "verbose", args: ["-v"], on: true, needs: ALL,
|
|
150
|
+
blurb: "list each file (drives the progress readout)"),
|
|
151
|
+
Flag.new(id: :preserve, label: "preserve permissions", args: ["-p"], on: true, needs: BOTH,
|
|
152
|
+
blurb: "keep modes exactly as on disk"),
|
|
153
|
+
Flag.new(id: :xattrs, label: "extended attributes", args: ["--xattrs", "--acls"], on: false, needs: BOTH,
|
|
154
|
+
blurb: "store xattrs and POSIX ACLs"),
|
|
155
|
+
Flag.new(id: :deref, label: "follow symlinks", args: ["-h"], on: false, needs: BOTH,
|
|
156
|
+
blurb: "archive link targets instead of the links"),
|
|
157
|
+
Flag.new(id: :onefs, label: "one file system", args: ["--one-file-system"], on: false, needs: BOTH,
|
|
158
|
+
blurb: "do not cross mount points"),
|
|
159
|
+
Flag.new(id: :numeric, label: "numeric owner", args: ["--numeric-owner"], on: false, needs: BOTH,
|
|
160
|
+
blurb: "store uid/gid, not names"),
|
|
161
|
+
Flag.new(id: :sparse, label: "sparse files", args: ["-S"], on: false, needs: GNU,
|
|
162
|
+
blurb: "store holes efficiently"),
|
|
163
|
+
Flag.new(id: :excl_vcs, label: "exclude VCS dirs", args: ["--exclude-vcs"], on: false, needs: GNU,
|
|
164
|
+
blurb: "skip .git, .hg, .svn"),
|
|
165
|
+
Flag.new(id: :excl_ign, label: "honour .gitignore", args: ["--exclude-vcs-ignores"], on: false, needs: GNU,
|
|
166
|
+
blurb: "skip files your VCS ignores"),
|
|
167
|
+
Flag.new(id: :sorted, label: "reproducible order", args: ["--sort=name"], on: false, needs: GNU,
|
|
168
|
+
blurb: "deterministic member order"),
|
|
169
|
+
Flag.new(id: :keepgoing, label: "ignore read errors", args: ["--ignore-failed-read"], on: false, needs: GNU,
|
|
170
|
+
blurb: "do not abort on unreadable files")
|
|
171
|
+
].select { |f| f.supported?(flavor) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.zip_flags
|
|
175
|
+
[
|
|
176
|
+
Flag.new(id: :verbose, label: "verbose", args: [], on: true,
|
|
177
|
+
blurb: "list each file (drives the progress readout)"),
|
|
178
|
+
Flag.new(id: :symlinks, label: "store symlinks", args: ["-y"], on: true,
|
|
179
|
+
blurb: "keep links as links, do not follow"),
|
|
180
|
+
Flag.new(id: :dirs, label: "directory entries", args: [], on: true,
|
|
181
|
+
blurb: "record folders explicitly"),
|
|
182
|
+
Flag.new(id: :junk, label: "junk paths", args: ["-j"], on: false,
|
|
183
|
+
blurb: "flatten everything into the root"),
|
|
184
|
+
Flag.new(id: :noextra, label: "strip extra attributes", args: ["-X"], on: false,
|
|
185
|
+
blurb: "no uid/gid or timestamps beyond the basics"),
|
|
186
|
+
Flag.new(id: :oldest, label: "archive time = newest entry", args: ["-o"], on: false,
|
|
187
|
+
blurb: "reproducible-ish archive mtime")
|
|
188
|
+
]
|
|
189
|
+
end
|
|
190
|
+
end
|
data/lib/rakpak/job.rb
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "plan"
|
|
4
|
+
|
|
5
|
+
module Rakpak
|
|
6
|
+
# Runs a plan's steps on a worker thread, streaming output into a ring
|
|
7
|
+
# buffer. The UI polls it; backgrounding is just "stop looking at it".
|
|
8
|
+
class Job
|
|
9
|
+
KEEP = 400
|
|
10
|
+
|
|
11
|
+
attr_reader :plan, :state, :started_at, :finished_at, :exit_status,
|
|
12
|
+
:error, :step_index, :steps, :file_count, :current_file
|
|
13
|
+
|
|
14
|
+
def initialize(plan, total_files: nil)
|
|
15
|
+
@plan = plan
|
|
16
|
+
@steps = plan.steps
|
|
17
|
+
@total_files = total_files
|
|
18
|
+
@lines = []
|
|
19
|
+
@lock = Mutex.new
|
|
20
|
+
@state = :pending
|
|
21
|
+
@step_index = 0
|
|
22
|
+
@file_count = 0
|
|
23
|
+
@current_file = nil
|
|
24
|
+
@verbose = true
|
|
25
|
+
@cancel = false
|
|
26
|
+
@pid = nil
|
|
27
|
+
@spawned = nil # output path of the step currently being written
|
|
28
|
+
@started_at = nil
|
|
29
|
+
@finished_at = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def start
|
|
33
|
+
@started_at = now
|
|
34
|
+
@state = :running
|
|
35
|
+
@thread = Thread.new { run_all }
|
|
36
|
+
@thread.abort_on_exception = false
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def running? = @state == :running
|
|
41
|
+
def done? = %i[done failed cancelled].include?(@state)
|
|
42
|
+
def ok? = @state == :done
|
|
43
|
+
|
|
44
|
+
def elapsed
|
|
45
|
+
return 0 unless @started_at
|
|
46
|
+
|
|
47
|
+
(@finished_at || now) - @started_at
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def tail(n)
|
|
51
|
+
@lock.synchronize { @lines.last(n).dup }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def label
|
|
55
|
+
@lock.synchronize { @steps[[@step_index, @steps.size - 1].min]&.first || "archive" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def total_files = @total_files
|
|
59
|
+
|
|
60
|
+
# nil when there is nothing to measure progress against: no file total,
|
|
61
|
+
# or a step whose tool is not listing members (verbose off).
|
|
62
|
+
def fraction
|
|
63
|
+
return nil unless @total_files&.positive? && @verbose
|
|
64
|
+
|
|
65
|
+
per = 1.0 / @steps.size
|
|
66
|
+
base = @step_index * per
|
|
67
|
+
[base + (per * [@file_count.to_f / @total_files, 1.0].min), 1.0].min
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Bytes on disk for whatever this step is writing right now.
|
|
71
|
+
def output_size
|
|
72
|
+
out = @plan.outputs[[@step_index, @plan.outputs.size - 1].min]
|
|
73
|
+
return nil unless out
|
|
74
|
+
|
|
75
|
+
File.size(out)
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def summary
|
|
81
|
+
@plan.outputs.map do |o|
|
|
82
|
+
size = begin
|
|
83
|
+
File.size(o)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
[File.basename(o), size]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cancel
|
|
92
|
+
@cancel = true
|
|
93
|
+
kill_current
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Blocks until the worker is finished, for at most `secs`.
|
|
97
|
+
def wait(secs = nil) = @thread&.join(secs)
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
|
+
|
|
103
|
+
def kill_current
|
|
104
|
+
pid = @pid
|
|
105
|
+
return unless pid
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
Process.kill("TERM", -pid) # the whole group: tar and its compressor
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
Thread.new do
|
|
113
|
+
sleep 2
|
|
114
|
+
# Once waitpid has reaped it the pid may belong to someone else.
|
|
115
|
+
next unless @pid == pid
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
Process.kill("KILL", -pid)
|
|
119
|
+
rescue StandardError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def push(line)
|
|
126
|
+
return if line.empty?
|
|
127
|
+
|
|
128
|
+
@lock.synchronize do
|
|
129
|
+
@lines << line
|
|
130
|
+
@lines.shift(@lines.size - KEEP) if @lines.size > KEEP
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def run_all
|
|
135
|
+
@steps.each_with_index do |(label, argv, verbose, stdout), idx|
|
|
136
|
+
@step_index = idx
|
|
137
|
+
@file_count = 0
|
|
138
|
+
@verbose = verbose
|
|
139
|
+
# A cancel that lands between steps must not start the next one.
|
|
140
|
+
return finish(:cancelled) if @cancel
|
|
141
|
+
|
|
142
|
+
push("▸ #{label}: #{Plan.show_cmd(argv, stdout)}")
|
|
143
|
+
status = run_step(argv, stdout)
|
|
144
|
+
return finish(:cancelled) if @cancel
|
|
145
|
+
|
|
146
|
+
unless status&.success?
|
|
147
|
+
@error = failure_message(label, status)
|
|
148
|
+
return finish(:failed)
|
|
149
|
+
end
|
|
150
|
+
@spawned = nil
|
|
151
|
+
end
|
|
152
|
+
finish(:done)
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
@error = "#{e.class}: #{e.message}"
|
|
155
|
+
finish(:failed)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def failure_message(label, status)
|
|
159
|
+
# status is nil when the command could not be spawned at all; in that
|
|
160
|
+
# case run_step already recorded the useful message.
|
|
161
|
+
return @error || "#{label} could not be started" if status.nil?
|
|
162
|
+
|
|
163
|
+
if status.signaled?
|
|
164
|
+
name = Signal.signame(status.termsig) || status.termsig.to_s
|
|
165
|
+
return "#{label} killed by SIG#{name}"
|
|
166
|
+
end
|
|
167
|
+
@exit_status = status.exitstatus
|
|
168
|
+
"#{label} exited with status #{@exit_status}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def finish(state)
|
|
172
|
+
cleanup_incomplete if %i[failed cancelled].include?(state)
|
|
173
|
+
@state = state
|
|
174
|
+
@finished_at = now
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A half-written archive is worse than no archive: it opens, lists a few
|
|
178
|
+
# members, then fails. Remove it, but only the output of the step that
|
|
179
|
+
# was actually interrupted. Archives finished by earlier steps are whole
|
|
180
|
+
# and must survive, and a step that never spawned wrote nothing.
|
|
181
|
+
def cleanup_incomplete
|
|
182
|
+
path = @spawned
|
|
183
|
+
return unless path && File.exist?(path)
|
|
184
|
+
|
|
185
|
+
File.unlink(path)
|
|
186
|
+
push("removed incomplete #{File.basename(path)}")
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
push("could not remove #{File.basename(path)}: #{e.message}")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# `stdout` names a file the command's output is the archive for (gzip -c);
|
|
192
|
+
# otherwise stdout joins stderr in the log.
|
|
193
|
+
def run_step(argv, stdout = nil)
|
|
194
|
+
out = @plan.outputs[@step_index]
|
|
195
|
+
# Every tool here is asked to create the archive, and the confirm
|
|
196
|
+
# screen has said an existing one will be overwritten. zip would
|
|
197
|
+
# otherwise update it in place, keeping members that no longer exist.
|
|
198
|
+
File.unlink(out) if out && File.exist?(out)
|
|
199
|
+
@spawned = out
|
|
200
|
+
|
|
201
|
+
rd, wr = IO.pipe
|
|
202
|
+
begin
|
|
203
|
+
pid = Process.spawn(*argv, out: stdout ? [stdout, "wb"] : wr, err: wr, in: File::NULL,
|
|
204
|
+
pgroup: true, chdir: @plan.base)
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
# Nothing was spawned, so nothing will close these for us.
|
|
207
|
+
rd.close
|
|
208
|
+
wr.close
|
|
209
|
+
@error = e.message
|
|
210
|
+
return nil
|
|
211
|
+
end
|
|
212
|
+
wr.close
|
|
213
|
+
@pid = pid
|
|
214
|
+
# A cancel that raced the spawn saw no pid to signal; do it now.
|
|
215
|
+
kill_current if @cancel
|
|
216
|
+
|
|
217
|
+
buf = String.new("", encoding: Encoding::UTF_8)
|
|
218
|
+
begin
|
|
219
|
+
loop do
|
|
220
|
+
chunk = begin
|
|
221
|
+
rd.read_nonblock(16_384)
|
|
222
|
+
rescue IO::WaitReadable
|
|
223
|
+
IO.select([rd], nil, nil, 0.25)
|
|
224
|
+
next
|
|
225
|
+
rescue EOFError
|
|
226
|
+
break
|
|
227
|
+
end
|
|
228
|
+
# A pipe hands back binary. Filenames are arbitrary bytes, so this
|
|
229
|
+
# has to be made printable before it can meet the UTF-8 frame.
|
|
230
|
+
buf << chunk.force_encoding(Encoding::UTF_8).scrub("·")
|
|
231
|
+
# tar emits newlines, zip rewrites lines with \r.
|
|
232
|
+
while (m = buf.match(/[\r\n]/))
|
|
233
|
+
line = buf.slice!(0, m.end(0)).chomp("\n").chomp("\r").rstrip
|
|
234
|
+
record(line)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
ensure
|
|
238
|
+
record(buf.rstrip) unless buf.strip.empty?
|
|
239
|
+
rd.close unless rd.closed?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
_, status = Process.waitpid2(@pid)
|
|
243
|
+
@pid = nil
|
|
244
|
+
status
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def record(line)
|
|
248
|
+
return if line.empty?
|
|
249
|
+
|
|
250
|
+
case line
|
|
251
|
+
when /\A\s*(?:adding|updating|deflated|stored):\s*(.+?)(?:\s*\(|\z)/
|
|
252
|
+
@file_count += 1
|
|
253
|
+
@current_file = Regexp.last_match(1)
|
|
254
|
+
when /\A(?:tar|zip|gzip|zstd|xz|bzip2|brotli|lz4):/, /\A\s*(?:total bytes|zip warning)/
|
|
255
|
+
@current_file = line
|
|
256
|
+
else
|
|
257
|
+
# Without -v the only lines are diagnostics, not members.
|
|
258
|
+
@file_count += 1 if @verbose
|
|
259
|
+
@current_file = line
|
|
260
|
+
end
|
|
261
|
+
push(line)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|