rakpak 1.1.0 → 1.2.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.
data/lib/rakpak/plan.rb CHANGED
@@ -4,19 +4,12 @@ require_relative "formats"
4
4
  require_relative "text"
5
5
 
6
6
  module Rakpak
7
- # Turns a set of tagged paths plus the wizard's answers into one concrete
8
- # command. Nothing here shells out; commands are spawned without a shell,
9
- # so spaces and quotes in filenames are never a hazard.
10
- #
11
- # Three shapes of output, one file each:
12
- # :both tar, then compress name.tar.gz, name.tar.zst, ...
13
- # :tar plain tar, no compression name.tar
14
- # :zip compression only name.zip, or name.txt.gz for one file
7
+ # :both tar then compress, :tar plain tar, :zip compress only (name.zip, or name.txt.gz for one file)
15
8
  class Plan
16
9
  TARGETS = %i[both tar zip].freeze
17
10
 
18
- attr_reader :paths
19
- attr_accessor :outdir, :basename, :target
11
+ attr_reader :paths, :excluded
12
+ attr_accessor :outdir, :basename, :target, :excludes
20
13
  attr_accessor :tar_codec, :tar_level, :tar_flags, :compressor, :comp_level, :zip_flags
21
14
 
22
15
  def initialize(paths:, outdir:, basename: "archive", target: :both)
@@ -24,7 +17,8 @@ module Rakpak
24
17
  @outdir = outdir
25
18
  @basename = basename
26
19
  @target = target
27
- # gzip is the one every system can read; anything else is opt-in.
20
+ @excludes = []
21
+ @excluded = []
28
22
  @tar_codec = TAR_CODECS.find { |c| c.id == :gzip && c.available? } || tar_codec_fallback
29
23
  @tar_level = @tar_codec.default
30
24
  @tar_flags = Rakpak.tar_flags
@@ -33,12 +27,8 @@ module Rakpak
33
27
  @zip_flags = Rakpak.zip_flags
34
28
  end
35
29
 
36
- # Drop anything already covered by another selection: tagging ~/docs and
37
- # then ~/docs/notes would otherwise store notes twice, silently.
38
30
  def self.prune(paths)
39
- # Sorting by path components puts every descendant right after its
40
- # ancestor ("a/b" before "a-x"), so one pass with a stack of accepted
41
- # ancestors is enough, however many paths there are.
31
+ # Sorting by components puts descendants right after their ancestor ("a/b" before "a-x").
42
32
  sorted = paths.map { |p| File.expand_path(p) }.uniq.sort_by { |p| p.split("/") }
43
33
  kept = []
44
34
  stack = []
@@ -56,15 +46,29 @@ module Rakpak
56
46
  path.start_with?(dir == "/" ? "/" : "#{dir}/")
57
47
  end
58
48
 
59
- # "none" needs no tool so it is always available; it is the last
60
- # resort, not the first pick, when gzip is missing.
49
+ def self.parse_excludes(text)
50
+ slash = "/".ord
51
+ text.split(",").map do |name|
52
+ name = name.strip
53
+ finish = name.bytesize
54
+ finish -= 1 while finish.positive? && name.getbyte(finish - 1) == slash
55
+ name.byteslice(0, finish)
56
+ end.reject(&:empty?).uniq
57
+ end
58
+
59
+ # Marked paths are exact, so glob characters in them must not match.
60
+ def self.literal(path) = path.gsub(/[\\*?\[\]]/) { |c| "\\#{c}" }
61
+
62
+ # Only marks strictly inside a packed folder count; a packed item itself is untagged instead.
63
+ def excluded=(list)
64
+ @excluded = list.map { |p| File.expand_path(p) }.uniq.sort
65
+ .select { |x| @paths.any? { |p| Plan.inside?(x, p) } }
66
+ end
67
+
61
68
  def tar_codec_fallback
62
69
  TAR_CODECS.find { |c| c.bin && c.available? } || Rakpak.tar_codec(:none)
63
70
  end
64
71
 
65
- # One format's knobs behind a uniform face, so the option form does not
66
- # need to know whether it is editing tar or compressor settings. Picking
67
- # a codec resets the level to that codec's default.
68
72
  class Side
69
73
  attr_reader :options, :flags
70
74
 
@@ -83,7 +87,7 @@ module Rakpak
83
87
  @plan.public_send("#{@level_attr}=", v)
84
88
  end
85
89
 
86
- # [label, id, enabled, why] rows for the form's choice list.
90
+ # [label, id, enabled, why] rows.
87
91
  def choices = @plan.public_send(@choices)
88
92
 
89
93
  def codec=(id)
@@ -103,13 +107,10 @@ module Rakpak
103
107
  flags: @zip_flags, choices: :compress_choices)
104
108
  end
105
109
 
106
- # "both" means compressed, so "none" is not on offer there.
107
110
  def tar_choices
108
111
  TAR_CODECS.reject { |c| c.id == :none }.map { |c| [c.label, c.id, c.available?, c.why_not] }
109
112
  end
110
113
 
111
- # Single-file compressors only make sense for exactly one file; zip can
112
- # take anything.
113
114
  def compress_choices
114
115
  COMPRESSORS.map do |c|
115
116
  ok = c.available? && (c.container? || single_file?)
@@ -120,13 +121,8 @@ module Rakpak
120
121
 
121
122
  def single_file? = @paths.size == 1 && File.file?(@paths.first)
122
123
 
123
- # True when the output is a bare compressed file (notes.txt.gz), where
124
- # the original name should be kept whole.
125
124
  def single_compress? = @target == :zip && !@compressor.container?
126
125
 
127
- # Deepest directory containing every tagged path. Members are stored
128
- # relative to it, so the archive has a sane shape no matter how far
129
- # apart the selections were.
130
126
  def base
131
127
  @base ||= begin
132
128
  dirs = @paths.map { |p| File.dirname(p) }
@@ -142,11 +138,11 @@ module Rakpak
142
138
  end
143
139
  end
144
140
 
145
- def members
146
- @paths.map do |p|
147
- rel = p.delete_prefix(base == "/" ? "/" : "#{base}/")
148
- rel.empty? ? File.basename(p) : rel
149
- end
141
+ def members = @paths.map { |p| relative(p) }
142
+
143
+ def relative(path)
144
+ rel = path.delete_prefix(base == "/" ? "/" : "#{base}/")
145
+ rel.empty? ? File.basename(path) : rel
150
146
  end
151
147
 
152
148
  def ext
@@ -160,21 +156,16 @@ module Rakpak
160
156
  def output = File.join(@outdir, ensure_ext(@basename, ext))
161
157
  def outputs = [output]
162
158
 
163
- # Nothing to make ready: the destination folder was checked, not created,
164
- # and every step writes straight to its final name.
165
159
  def prepare; end
166
160
  def commit; end
167
161
  def rollback; end
168
162
 
169
- # What the job view calls this work while it runs.
170
163
  def gerund = "archiving"
171
164
 
172
- # One line for the notice when it finishes.
173
165
  def outcome
174
166
  outputs.map { |o| "#{File.basename(o)} #{Text.bytes(file_size(o))}" }.join(" · ")
175
167
  end
176
168
 
177
- # What follows the path on the line printed to the shell afterwards.
178
169
  def report_note = Text.bytes(file_size(output))
179
170
 
180
171
  def file_size(path)
@@ -183,28 +174,23 @@ module Rakpak
183
174
  nil
184
175
  end
185
176
 
186
- # How many members the job should expect, for the progress bar. The
187
- # sizer has been counting the selection since it was tagged.
177
+ # Excluded files are inside the count, so a total would stall the bar; show the count alone.
188
178
  def total_members(sizer)
179
+ return nil if @excludes.any? || @excluded.any?
180
+
189
181
  files = sizer.total(@paths).files
190
182
  files.positive? ? files : nil
191
183
  end
192
184
 
193
- # Every step here writes one archive file, and the confirm screen has
194
- # said an existing one goes.
195
185
  def clobbers_output? = true
196
186
 
197
- # Extensions a user might type that we would otherwise double up.
198
187
  ARCHIVE_EXTS = (TAR_CODECS.map(&:ext) + TAR_CODECS.map(&:single_ext) + %w[.tgz .tbz2 .txz .zip])
199
188
  .reject(&:empty?).uniq.sort_by { |e| -e.length }.freeze
200
189
 
201
190
  def ensure_ext(name, ext)
202
191
  return name if name.downcase.end_with?(ext)
203
- # backup.tar gzipped on its own is backup.tar.gz; the name is the
204
- # point, so nothing is stripped from it.
205
192
  return "#{name}#{ext}" if single_compress?
206
193
 
207
- # Strip a competing archive extension the user may have typed.
208
194
  typed = ARCHIVE_EXTS.find { |e| name.downcase.end_with?(e) }
209
195
  stripped = typed ? name[0...-typed.length] : name
210
196
  stripped = name if stripped.empty?
@@ -217,10 +203,36 @@ module Rakpak
217
203
  argv += ["--use-compress-program", filter]
218
204
  end
219
205
  @tar_flags.each { |f| argv.concat(f.args) if f.on }
206
+ argv += tar_exclude_args
220
207
  argv += ["-f", output, "-C", base, "--"]
221
208
  argv + members
222
209
  end
223
210
 
211
+ # Names match at any depth. Marks are anchored: GNU tar takes --anchored, bsdtar a leading ^,
212
+ # busybox has neither (warnings says so).
213
+ def tar_exclude_args
214
+ args = @excludes.map { |n| "--exclude=#{n}" }
215
+ marked = @excluded.map { |p| Plan.literal(relative(p)) }
216
+ return args if marked.empty?
217
+
218
+ case Tools.tar_flavor
219
+ when *GNU then args + ["--anchored"] + marked.map { |m| "--exclude=#{m}" }
220
+ when :bsd then args + marked.map { |m| "--exclude=^#{m}" }
221
+ else args + marked.map { |m| "--exclude=#{m}" }
222
+ end
223
+ end
224
+
225
+ # zip matches -x against whole member paths, so a name needs each depth spelled out.
226
+ def zip_exclude_args
227
+ names = @excludes.map { |n| zipsafe(n) }.flat_map { |n| [n, "#{n}/*", "*/#{n}", "*/#{n}/*"] }
228
+ marks = @excluded.map { |p| zipsafe(Plan.literal(relative(p))) }.flat_map { |m| [m, "#{m}/*"] }
229
+ pats = names + marks
230
+ pats.empty? ? [] : ["-x", *pats]
231
+ end
232
+
233
+ # zip would read a leading dash as an option; [-] matches the same name.
234
+ def zipsafe(pat) = pat.start_with?("-") ? "[-]#{pat[1..]}" : pat
235
+
224
236
  def zip_argv
225
237
  argv = ["zip", "-r"]
226
238
  argv << (zip_verbose? ? "-v" : "-q")
@@ -233,11 +245,9 @@ module Rakpak
233
245
  argv.concat(f.args) if f.on
234
246
  end
235
247
  argv << output
236
- argv + members.map { |m| dashsafe(m) }
248
+ argv + members.map { |m| dashsafe(m) } + zip_exclude_args
237
249
  end
238
250
 
239
- # gzip and friends read one file and write to stdout; the job redirects
240
- # that into the output path.
241
251
  def single_argv
242
252
  @compressor.argv(@comp_level) + [dashsafe(members.first)]
243
253
  end
@@ -265,8 +275,7 @@ module Rakpak
265
275
  end
266
276
  end
267
277
 
268
- # Display form of the command. Execution never goes through a shell, so
269
- # this is for the reader's benefit; quote only what needs it.
278
+ # Display only; execution never goes through a shell.
270
279
  def self.show_arg(arg)
271
280
  arg.match?(%r{\A[\w@%+=:,./-]+\z}) ? arg : "'#{arg.gsub("'", %q('"'"'))}'"
272
281
  end
@@ -280,7 +289,6 @@ module Rakpak
280
289
  steps.map { |(label, argv, _, stdout)| [label, Plan.show_cmd(argv, stdout)] }
281
290
  end
282
291
 
283
- # Problems worth blocking on, checked right before the run.
284
292
  def problems
285
293
  errs = []
286
294
  errs << "nothing selected" if @paths.empty?
@@ -302,7 +310,6 @@ module Rakpak
302
310
  errs.compact.uniq
303
311
  end
304
312
 
305
- # Non-blocking things the confirm screen should say out loud.
306
313
  def warnings
307
314
  warn = []
308
315
  o = output
@@ -310,6 +317,14 @@ module Rakpak
310
317
  if @paths.any? { |p| Plan.inside?(o, p) }
311
318
  warn << "output sits inside a selected folder, so it may archive itself"
312
319
  end
320
+ @paths.each do |p|
321
+ name = File.basename(p)
322
+ hit = @excludes.find { |pat| File.fnmatch?(pat, name, File::FNM_DOTMATCH) }
323
+ warn << "#{name} matches #{hit}, so it is left out entirely" if hit
324
+ end
325
+ if @excluded.any? && @target != :zip && Tools.tar_flavor == :busybox
326
+ warn << "busybox tar cannot pin a mark to one spot; same-named paths deeper in go too"
327
+ end
313
328
  warn
314
329
  end
315
330
  end
data/lib/rakpak/screen.rb CHANGED
@@ -4,8 +4,6 @@ require_relative "text"
4
4
  require_relative "theme"
5
5
 
6
6
  module Rakpak
7
- # A character grid. Everything draws into cells, so modals overlay the
8
- # browser cleanly and the whole frame ships in one write.
9
7
  class Screen
10
8
  attr_reader :w, :h
11
9
 
@@ -25,7 +23,6 @@ module Rakpak
25
23
  @st.fill(style)
26
24
  end
27
25
 
28
- # Writes `text` at (x, y). Returns the column just past the text.
29
26
  def put(x, y, text, style = nil)
30
27
  return x if y.negative? || y >= @h
31
28
 
@@ -50,17 +47,14 @@ module Rakpak
50
47
  cx
51
48
  end
52
49
 
53
- # A double-width glyph owns two cells: itself and an empty marker after
54
- # it. Writing over either half must blank the other, or the row renders
55
- # one column too wide and the terminal wraps it.
50
+ # A wide glyph owns two cells; overwriting either half must blank the other or the row wraps.
56
51
  def clear_halves(idx)
57
52
  col = idx % @w
58
53
  @ch[idx - 1] = " " if @ch[idx] == "" && col.positive? && Text.gw(@ch[idx - 1]) == 2
59
54
  @ch[idx + 1] = " " if col < @w - 1 && @ch[idx + 1] == "" && Text.gw(@ch[idx]) == 2
60
55
  end
61
56
 
62
- # Last line of defence: a single binary byte reaching @ch would make the
63
- # whole frame fail to concatenate and take the app down mid-render.
57
+ # One binary byte in @ch would break frame concatenation mid-render.
64
58
  def printable(text)
65
59
  str = text.to_s
66
60
  str = str.dup.force_encoding(Encoding::UTF_8) unless str.encoding == Encoding::UTF_8
@@ -104,8 +98,6 @@ module Rakpak
104
98
  end
105
99
  end
106
100
 
107
- # Flatten everything to a faint monochrome so a modal reads as the
108
- # foreground layer.
109
101
  def veil(style = Theme::FAINT)
110
102
  @st.fill(style)
111
103
  end
data/lib/rakpak/sizer.rb CHANGED
@@ -1,12 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- # Walks tagged directories on a worker thread so the UI can show a real
5
- # byte total and file count without ever blocking on a huge tree.
6
4
  class Sizer
7
5
  Result = Struct.new(:bytes, :files, :partial)
8
6
 
9
- BUDGET = 4.0 # seconds per path before we report a partial figure
7
+ BUDGET = 4.0 # seconds per path before reporting a partial figure
10
8
 
11
9
  def initialize
12
10
  @cache = {}
@@ -40,7 +38,6 @@ module Rakpak
40
38
  Result.new(bytes, files, known.any?(&:nil?) || known.compact.any?(&:partial))
41
39
  end
42
40
 
43
- # Drops the figure, and any measurement in flight for it.
44
41
  def forget(path)
45
42
  @lock.synchronize do
46
43
  @cache.delete(path)
@@ -60,8 +57,7 @@ module Rakpak
60
57
  def work(path, token)
61
58
  res = measure(path)
62
59
  @lock.synchronize do
63
- # Forgotten, invalidated, or re-requested while we were walking:
64
- # only the walk that the current request started may answer it.
60
+ # Only the walk the current request started may answer it.
65
61
  next unless @pending[path].equal?(token)
66
62
 
67
63
  @pending.delete(path)
data/lib/rakpak/term.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require "io/console"
4
4
 
5
5
  module Rakpak
6
- # Raw-mode terminal control and key decoding.
7
6
  module Term
8
7
  ARROWS = { "A" => :up, "B" => :down, "C" => :right, "D" => :left,
9
8
  "H" => :home, "F" => :end }.freeze
@@ -52,8 +51,7 @@ module Rakpak
52
51
  $stdout.flush
53
52
  end
54
53
 
55
- # Blocks up to `timeout` seconds. Returns a key symbol, a printable
56
- # String, or nil on timeout.
54
+ # Returns a key symbol, a printable String, or nil on timeout.
57
55
  def wait_key(timeout = nil)
58
56
  return nil unless IO.select([$stdin], nil, nil, timeout)
59
57
 
@@ -78,10 +76,7 @@ module Rakpak
78
76
  end
79
77
  end
80
78
 
81
- # Under a C locale getc yields one byte at a time, and under UTF-8 a
82
- # stray byte arrives as a one-byte invalid string. Gather the rest of
83
- # the sequence when there is one; if the result is still not valid
84
- # text, the key is dropped rather than raised on later.
79
+ # C locale getc yields single bytes; gather the sequence and drop it if still invalid.
85
80
  def complete_utf8(c)
86
81
  s = c.dup.force_encoding(Encoding::UTF_8)
87
82
  return s if s.valid_encoding?
@@ -109,9 +104,7 @@ module Rakpak
109
104
  nil
110
105
  end
111
106
 
112
- # An arrow key arrives as several bytes. Over a slow link they can be
113
- # split across reads, so allow a generous window before concluding the
114
- # user pressed a bare Esc. The delay is only ever paid on a real Esc.
107
+ # Escape sequences can split across reads on slow links; only a real Esc pays this wait.
115
108
  ESC_WINDOW = 0.05
116
109
 
117
110
  def read_escape
@@ -139,7 +132,6 @@ module Rakpak
139
132
  :esc
140
133
  end
141
134
  else
142
- # Alt-<char>: the whole sequence is the character.
143
135
  :"alt_#{seq}"
144
136
  end
145
137
  end
data/lib/rakpak/text.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- # Grapheme-aware width math. Just enough Unicode to keep columns honest.
5
4
  module Text
6
5
  WIDE = [
7
6
  0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF,
@@ -14,12 +13,10 @@ module Rakpak
14
13
 
15
14
  module_function
16
15
 
17
- # Display width of a single grapheme cluster.
18
16
  def gw(cluster)
19
17
  cp = cluster.ord
20
- return 1 if cp >= 0x20 && cp < 0x7F # the common case, checked first
21
- # C0, DEL and the C1 controls: a UTF-8 terminal executes U+009B as
22
- # CSI, so these must never reach the frame.
18
+ return 1 if cp >= 0x20 && cp < 0x7F
19
+ # C0, DEL and C1: a UTF-8 terminal executes U+009B as CSI.
23
20
  return 0 if cp < 0x20 || cp == 0x7F || (cp >= 0x80 && cp <= 0x9F)
24
21
  return 0 if ZERO.any? { |r| r.cover?(cp) }
25
22
  return 2 if WIDE.any? { |r| r.cover?(cp) }
@@ -35,7 +32,6 @@ module Rakpak
35
32
  w
36
33
  end
37
34
 
38
- # Truncate to `max` columns, appending an ellipsis when something was cut.
39
35
  def fit(str, max)
40
36
  return "" if max <= 0
41
37
  return str if width(str) <= max
@@ -52,7 +48,6 @@ module Rakpak
52
48
  out << "…"
53
49
  end
54
50
 
55
- # Keep the tail of a path visible instead of the head.
56
51
  def fit_left(str, max)
57
52
  return "" if max <= 0
58
53
  return str if width(str) <= max
@@ -75,7 +70,6 @@ module Rakpak
75
70
  w >= max ? str : str + (" " * (max - w))
76
71
  end
77
72
 
78
- # Greedy wrap on spaces, with a hanging indent for continuations.
79
73
  def wrap(str, max, indent = 0)
80
74
  return [str] if max <= indent + 4 || width(str) <= max
81
75
 
@@ -84,7 +78,6 @@ module Rakpak
84
78
  out = []
85
79
  line = +""
86
80
  words = str[lead.length..].to_s.split(" ").flat_map do |word|
87
- # A path with no spaces can still be wider than the panel; break it.
88
81
  limit = max - indent
89
82
  next word if width(word) <= limit || limit < 8
90
83
 
@@ -103,7 +96,6 @@ module Rakpak
103
96
  out
104
97
  end
105
98
 
106
- # /home/me/x → ~/x. Only a real prefix counts: /home/me2 is left alone.
107
99
  def tilde(path)
108
100
  home = Dir.home
109
101
  return path if home.nil? || home.empty? || home == "/"
@@ -112,8 +104,7 @@ module Rakpak
112
104
  path.start_with?("#{home}/") ? "~#{path[home.length..]}" : path
113
105
  end
114
106
 
115
- # For text that goes to the terminal without passing through Screen:
116
- # control and C1 bytes could otherwise be executed as escape sequences.
107
+ # Control and C1 bytes could be executed as escape sequences.
117
108
  def plain(str)
118
109
  str.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?").gsub(/[\u0000-\u001f\u007f-\u009f]/, "?")
119
110
  end
data/lib/rakpak/theme.rb CHANGED
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- # SGR fragments. Every style is emitted after a reset, so they compose by
5
- # concatenation without needing to be self-contained.
4
+ # Every style is emitted after a reset, so fragments needn't be self-contained.
6
5
  module Theme
7
6
  RESET = "\e[0m"
8
7
 
data/lib/rakpak/unpack.rb CHANGED
@@ -5,22 +5,11 @@ require_relative "formats"
5
5
  require_relative "plan"
6
6
 
7
7
  module Rakpak
8
- # Turns one archive plus a destination into a command that unpacks it.
9
- # Wears the same face as Plan, so Job runs it without knowing which way
10
- # round the work goes.
11
- #
12
- # Three shapes, mirroring the ones Plan writes:
13
- # :tar tar, decompressing through the codec if there is one
14
- # :zip unzip
15
- # :single one compressed file back to one plain file
16
8
  class Unpack
17
- # An archive shape recognised by extension. `codec` names the TAR_CODECS
18
- # entry doing the compression, so the binary probing already done for
19
- # packing decides whether this can be read back.
9
+ # `codec` is a TAR_CODECS id, so packing's binary probing decides readability.
20
10
  Shape = Struct.new(:ext, :kind, :codec, keyword_init: true) do
21
11
  def tool = kind == :zip ? "unzip" : "tar"
22
12
 
23
- # The compressor's binary, when one is involved.
24
13
  def bin = codec && Rakpak.tar_codec(codec)&.bin
25
14
  end
26
15
 
@@ -47,7 +36,6 @@ module Rakpak
47
36
  Shape.new(ext: ".br", kind: :single, codec: :brotli)
48
37
  ].sort_by { |s| -s.ext.length }.freeze
49
38
 
50
- # nil when the name carries no extension we know how to open.
51
39
  def self.format(path)
52
40
  name = File.basename(path.to_s).downcase
53
41
  SHAPES.find { |s| name.end_with?(s.ext) && name.length > s.ext.length }
@@ -55,9 +43,7 @@ module Rakpak
55
43
 
56
44
  def self.archive?(path) = !format(path).nil?
57
45
 
58
- # "notes.tar.gz" -> "notes"; the whole archive extension goes. A name that
59
- # leaves nothing, "." or ".." behind would resolve to the destination's
60
- # parent once joined, so those keep the whole basename instead.
46
+ # A name reducing to "", "." or ".." would resolve to the parent, so keep the whole basename.
61
47
  def self.strip_ext(path)
62
48
  name = File.basename(path.to_s)
63
49
  shape = format(path)
@@ -65,13 +51,10 @@ module Rakpak
65
51
  ["", ".", ".."].include?(stripped) ? name : stripped
66
52
  end
67
53
 
68
- # The folder to make for the contents, or nil when the archive holds a
69
- # single file that should land beside you.
70
54
  def self.default_subdir(path)
71
55
  format(path)&.kind == :single ? nil : strip_ext(path)
72
56
  end
73
57
 
74
- # What a lone compressed file decompresses back into.
75
58
  def self.member_name(path) = strip_ext(path)
76
59
 
77
60
  attr_reader :archive, :shape
@@ -86,22 +69,17 @@ module Rakpak
86
69
  def kind = @shape&.kind
87
70
  def single? = kind == :single
88
71
 
89
- # Job chdirs here, so it has to exist by the time the first step runs.
72
+ # Job chdirs here, so it must exist before the first step runs.
90
73
  def base = @dest
91
74
 
92
- # A tarball or zip pours its members into the folder; a lone compressed
93
- # file has one real output, and Job may replace that.
94
75
  def outputs = [single? ? File.join(@dest, Unpack.member_name(@archive)) : @dest]
95
76
  def output = outputs.first
96
77
  def clobbers_output? = single?
97
78
 
98
- # The decompressor's output is opened for writing before it has read a
99
- # single byte of the archive, so it cannot be aimed at the file it is
100
- # meant to replace. It writes here and is renamed over the top on success.
79
+ # Output is opened before the archive is read, so write here and rename over on success.
101
80
  def scratch = File.join(@dest, ".#{Unpack.member_name(@archive)}.part")
102
81
 
103
82
  def prepare
104
- # Remember what we make, so a failure can leave the tree as it was.
105
83
  @made = []
106
84
  dir = @dest
107
85
  until File.directory?(dir) || dir == "/"
@@ -111,13 +89,11 @@ module Rakpak
111
89
  FileUtils.mkdir_p(@dest)
112
90
  end
113
91
 
114
- # Every step succeeded, so the recovered file may take its real name.
115
92
  def commit
116
93
  File.rename(scratch, output) if single? && File.exist?(scratch)
117
94
  end
118
95
 
119
- # Folders we created and never filled are ours to take back; anything that
120
- # was already on disk, or anything the run managed to write, stays.
96
+ # Remove only folders we created that stayed empty.
121
97
  def rollback
122
98
  (@made || []).each do |dir|
123
99
  Dir.rmdir(dir) if File.directory?(dir) && Dir.empty?(dir)
@@ -126,14 +102,11 @@ module Rakpak
126
102
  end
127
103
  end
128
104
 
129
- # Counting an archive's members means decompressing the whole thing
130
- # first, which is most of the work. The job counts up as it goes instead.
105
+ # Counting members would mean decompressing the whole archive first.
131
106
  def total_members(_sizer) = nil
132
107
 
133
108
  def gerund = "unpacking"
134
109
 
135
- # A recovered lone file can be weighed. A folder of members has no useful
136
- # size of its own, so it reports what happened instead.
137
110
  def outcome
138
111
  return "#{File.basename(output)} #{Text.bytes(file_size(output))}" if single?
139
112
 
@@ -150,17 +123,13 @@ module Rakpak
150
123
 
151
124
  def tar_argv
152
125
  argv = ["tar", "-x", "-v"]
153
- # Reading an archive, tar runs the program with -d appended, so this is
154
- # the bare binary. Adding our own -d makes brotli refuse the command as
155
- # already set; the other five ignore the repeat.
156
- argv += ["--use-compress-program", @shape.bin] if @shape.bin
126
+ # GNU tar appends -d when reading (brotli refuses a repeated -d); bsdtar runs the string as given.
127
+ argv += ["--use-compress-program", Tools.tar_flavor == :bsd ? "#{@shape.bin} -d" : @shape.bin] if @shape.bin
157
128
  argv + ["-f", @archive, "-C", @dest]
158
129
  end
159
130
 
160
131
  def zip_argv = ["unzip", "-o", @archive, "-d", @dest]
161
132
 
162
- # The compressor reading the archive and writing the plain file to
163
- # stdout, which Job points at the output path.
164
133
  def single_argv = [@shape.bin, "-dc", @archive]
165
134
 
166
135
  # [label, argv, expects_verbose_output, stdout_path]
@@ -182,8 +151,6 @@ module Rakpak
182
151
  # GNU tar and bsdtar can hand the stream to a compressor; busybox cannot.
183
152
  def tar_pipes? = Tools.tar_pipes?
184
153
 
185
- # Every tool this run needs, so a half-installed machine is caught on
186
- # the confirm screen rather than partway through the extraction.
187
154
  def tools = [@shape&.kind == :single ? nil : @shape&.tool, @shape&.bin].compact
188
155
 
189
156
  def problems
@@ -200,8 +167,7 @@ module Rakpak
200
167
  end
201
168
  # mkdir_p would raise EEXIST partway through the run; say it up front.
202
169
  errs << "not a folder: #{@dest}" if File.exist?(@dest) && !File.directory?(@dest)
203
- # A folder can be writable and still refuse to be listed, which tar
204
- # needs to do to avoid clobbering, and which warnings needs to read.
170
+ # tar needs to list the folder to avoid clobbering.
205
171
  if File.directory?(@dest) && !File.readable?(@dest)
206
172
  errs << "destination is not readable: #{@dest}"
207
173
  end
@@ -210,8 +176,6 @@ module Rakpak
210
176
  errs.uniq
211
177
  end
212
178
 
213
- # Nothing is created until the job runs, so writability is a question
214
- # about the nearest folder that already exists.
215
179
  def existing_parent(dir)
216
180
  dir = File.dirname(dir) until File.directory?(dir) || dir == "/"
217
181
  dir
@@ -227,8 +191,7 @@ module Rakpak
227
191
  warn
228
192
  end
229
193
 
230
- # An unlistable folder is reported by problems; there is nothing to warn
231
- # about and this must not raise, since the confirm screen draws it.
194
+ # Must not raise: the confirm screen calls this while drawing.
232
195
  def empty_dir?(dir)
233
196
  Dir.children(dir).empty?
234
197
  rescue StandardError
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end