rakpak 1.0.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.
- checksums.yaml +4 -4
- data/README.md +63 -9
- data/lib/rakpak/app.rb +153 -61
- data/lib/rakpak/browser.rb +89 -38
- data/lib/rakpak/dir_cache.rb +1 -3
- data/lib/rakpak/entry.rb +1 -4
- data/lib/rakpak/formats.rb +2 -16
- data/lib/rakpak/job.rb +50 -28
- data/lib/rakpak/modal.rb +72 -25
- data/lib/rakpak/plan.rb +96 -46
- data/lib/rakpak/screen.rb +2 -10
- data/lib/rakpak/sizer.rb +2 -6
- data/lib/rakpak/term.rb +3 -11
- data/lib/rakpak/text.rb +3 -12
- data/lib/rakpak/theme.rb +1 -2
- data/lib/rakpak/unpack.rb +201 -0
- data/lib/rakpak/version.rb +1 -1
- data/lib/rakpak.rb +83 -14
- metadata +10 -8
data/lib/rakpak/browser.rb
CHANGED
|
@@ -2,24 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
require "set"
|
|
4
4
|
require_relative "dir_cache"
|
|
5
|
+
require_relative "plan"
|
|
5
6
|
require_relative "theme"
|
|
6
7
|
require_relative "text"
|
|
7
8
|
|
|
8
9
|
module Rakpak
|
|
9
|
-
# The yazi-style three-pane file browser: parent, current, preview.
|
|
10
|
-
# Owns navigation and the tag set; knows nothing about archiving.
|
|
11
10
|
class Browser
|
|
12
|
-
|
|
11
|
+
# Marking exclusions mid-pack: walking stays under root, back is where to return.
|
|
12
|
+
Scope = Struct.new(:root, :paths, :back)
|
|
13
|
+
|
|
14
|
+
attr_reader :cwd, :tags, :excludes, :filter
|
|
13
15
|
|
|
14
16
|
def initialize(start_dir = Dir.pwd, sizer: nil)
|
|
15
17
|
@dirs = DirCache.new
|
|
16
18
|
@sizer = sizer
|
|
17
19
|
@tags = Set.new
|
|
20
|
+
@excludes = Set.new
|
|
21
|
+
@scope = nil
|
|
18
22
|
@cursor = {}
|
|
19
23
|
@show_hidden = false
|
|
20
24
|
@filter = nil
|
|
21
25
|
@filtering = false
|
|
22
|
-
@show_queue = true
|
|
26
|
+
@show_queue = true
|
|
23
27
|
@pending = nil
|
|
24
28
|
@preview_cache = {}
|
|
25
29
|
@cwd = resolve(start_dir)
|
|
@@ -31,10 +35,12 @@ module Rakpak
|
|
|
31
35
|
d
|
|
32
36
|
end
|
|
33
37
|
|
|
34
|
-
# ---------------------------------------------------------------- state
|
|
35
|
-
|
|
36
38
|
def entries
|
|
37
39
|
list = @dirs.list(@cwd, hidden: @show_hidden) || []
|
|
40
|
+
# Above the packed items, only they are reachable.
|
|
41
|
+
if @scope && @cwd == @scope.root && !@scope.paths.include?(@cwd)
|
|
42
|
+
list = list.select { |e| @scope.paths.include?(e.path) }
|
|
43
|
+
end
|
|
38
44
|
return list unless @filter && !@filter.empty?
|
|
39
45
|
|
|
40
46
|
needle = @filter.downcase
|
|
@@ -54,18 +60,19 @@ module Rakpak
|
|
|
54
60
|
def show_queue? = @show_queue
|
|
55
61
|
def filtering? = @filtering
|
|
56
62
|
|
|
63
|
+
def scoped? = !@scope.nil?
|
|
64
|
+
|
|
57
65
|
def tag(path)
|
|
58
66
|
path = File.expand_path(path)
|
|
67
|
+
@excludes.delete(path)
|
|
59
68
|
return unless @tags.add?(path)
|
|
60
69
|
|
|
61
|
-
# A figure may linger from a cursor-only pack of this path;
|
|
62
|
-
# must not trust it.
|
|
70
|
+
# A figure may linger from a cursor-only pack of this path; don't trust it.
|
|
63
71
|
@sizer&.forget(path)
|
|
64
72
|
@sizer&.request(path)
|
|
65
73
|
end
|
|
66
74
|
|
|
67
|
-
#
|
|
68
|
-
# its figure; re-tagging later must measure again, not serve a stale one.
|
|
75
|
+
# All tag removals must pass here so the sizer drops its now-stale figure.
|
|
69
76
|
def untag(path)
|
|
70
77
|
return unless @tags.delete?(path)
|
|
71
78
|
|
|
@@ -78,17 +85,55 @@ module Rakpak
|
|
|
78
85
|
@tags.include?(entry.path) ? untag(entry.path) : tag(entry.path)
|
|
79
86
|
end
|
|
80
87
|
|
|
88
|
+
def exclude(path)
|
|
89
|
+
path = File.expand_path(path)
|
|
90
|
+
untag(path)
|
|
91
|
+
@excludes << path
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def toggle_exclude(entry = current)
|
|
95
|
+
return unless entry
|
|
96
|
+
|
|
97
|
+
@excludes.delete?(entry.path) || exclude(entry.path)
|
|
98
|
+
end
|
|
99
|
+
|
|
81
100
|
def tag_all = entries.each { |e| tag(e.path) }
|
|
82
101
|
def untag_all_here = entries.each { |e| untag(e.path) }
|
|
83
|
-
|
|
102
|
+
|
|
103
|
+
def clear_tags
|
|
104
|
+
@tags.to_a.each { |p| untag(p) }
|
|
105
|
+
@excludes.clear
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One packed folder opens inside it; several open at their common parent.
|
|
109
|
+
def scope_to(paths, base)
|
|
110
|
+
root = paths.size == 1 && File.directory?(paths.first) ? paths.first : base
|
|
111
|
+
@scope = Scope.new(root, paths, [@cwd, @cursor[@cwd], @filter])
|
|
112
|
+
@cwd = root
|
|
113
|
+
@filter = nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def leave_scope
|
|
117
|
+
@cwd, cursor, @filter = @scope.back
|
|
118
|
+
@cursor[@cwd] = cursor
|
|
119
|
+
@scope = nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def mark_in_scope
|
|
123
|
+
e = current
|
|
124
|
+
return unless e
|
|
125
|
+
return :unmarkable unless @scope.paths.any? { |p| Plan.inside?(e.path, p) }
|
|
126
|
+
|
|
127
|
+
toggle_exclude(e)
|
|
128
|
+
move(1)
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
84
131
|
|
|
85
132
|
def replace_tags(list)
|
|
86
133
|
(@tags.to_a - list).each { |p| untag(p) }
|
|
87
134
|
list.each { |p| tag(p) }
|
|
88
135
|
end
|
|
89
136
|
|
|
90
|
-
# Selections implied by the current state: explicit tags, else whatever
|
|
91
|
-
# the cursor is sitting on.
|
|
92
137
|
def selection
|
|
93
138
|
return @tags.to_a.sort if @tags.any?
|
|
94
139
|
|
|
@@ -104,14 +149,10 @@ module Rakpak
|
|
|
104
149
|
@tags.each { |p| @sizer.request(p) }
|
|
105
150
|
end
|
|
106
151
|
|
|
107
|
-
# ----------------------------------------------------------- navigation
|
|
108
|
-
|
|
109
152
|
def move(delta)
|
|
110
153
|
self.index = index + delta
|
|
111
154
|
end
|
|
112
155
|
|
|
113
|
-
# One step at a time wraps: up from the top lands on the last entry,
|
|
114
|
-
# down from the bottom on the first. Page moves still stop at the ends.
|
|
115
156
|
def step(delta)
|
|
116
157
|
n = entries.size
|
|
117
158
|
return if n.zero?
|
|
@@ -130,7 +171,7 @@ module Rakpak
|
|
|
130
171
|
end
|
|
131
172
|
|
|
132
173
|
def ascend
|
|
133
|
-
return if @cwd == "/"
|
|
174
|
+
return if @cwd == "/" || @cwd == @scope&.root
|
|
134
175
|
|
|
135
176
|
child = @cwd
|
|
136
177
|
@cwd = File.dirname(@cwd)
|
|
@@ -142,13 +183,13 @@ module Rakpak
|
|
|
142
183
|
def goto(dir)
|
|
143
184
|
d = File.expand_path(dir)
|
|
144
185
|
return false unless File.directory?(d) && File.readable?(d)
|
|
186
|
+
return false if @scope && d != @scope.root && !Plan.inside?(d, @scope.root)
|
|
145
187
|
|
|
146
188
|
@cwd = d
|
|
147
189
|
@filter = nil
|
|
148
190
|
true
|
|
149
191
|
end
|
|
150
192
|
|
|
151
|
-
# Open the folder holding `path` with the cursor on it.
|
|
152
193
|
def jump_to(path)
|
|
153
194
|
path = File.expand_path(path)
|
|
154
195
|
return false unless goto(File.dirname(path))
|
|
@@ -159,7 +200,6 @@ module Rakpak
|
|
|
159
200
|
!idx.nil?
|
|
160
201
|
end
|
|
161
202
|
|
|
162
|
-
# Returns :quit, :archive, :tags, :help, :jobs or nil.
|
|
163
203
|
def handle(key)
|
|
164
204
|
return handle_filter(key) if @filtering
|
|
165
205
|
|
|
@@ -170,8 +210,15 @@ module Rakpak
|
|
|
170
210
|
when "h" then return goto(Dir.home) && nil
|
|
171
211
|
when "r" then return goto("/") && nil
|
|
172
212
|
end
|
|
173
|
-
#
|
|
174
|
-
|
|
213
|
+
# Not a chord: handle it as its own key so a stray g never swallows q, p or space.
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if @scope
|
|
217
|
+
case key
|
|
218
|
+
when :space, "x" then return mark_in_scope
|
|
219
|
+
when "p", :esc then return :resume
|
|
220
|
+
when "a", "d", "D", "u", "T", "b" then return nil
|
|
221
|
+
end
|
|
175
222
|
end
|
|
176
223
|
|
|
177
224
|
result = case key
|
|
@@ -188,6 +235,9 @@ module Rakpak
|
|
|
188
235
|
when :space
|
|
189
236
|
toggle_tag
|
|
190
237
|
move(1)
|
|
238
|
+
when "x"
|
|
239
|
+
toggle_exclude
|
|
240
|
+
move(1)
|
|
191
241
|
when "a" then tag_all
|
|
192
242
|
when "d" then untag_all_here
|
|
193
243
|
when "D" then clear_tags
|
|
@@ -197,12 +247,13 @@ module Rakpak
|
|
|
197
247
|
when :ctrl_r then refresh!
|
|
198
248
|
when "~" then goto(Dir.home)
|
|
199
249
|
when "p" then :archive
|
|
250
|
+
when "u" then :unpack
|
|
200
251
|
when "T" then :tags
|
|
201
252
|
when "?" then :help
|
|
202
253
|
when "b" then :jobs
|
|
203
254
|
when "q", :ctrl_c then :quit
|
|
204
255
|
end
|
|
205
|
-
%i[archive tags help jobs quit].include?(result) ? result : nil
|
|
256
|
+
%i[archive unpack tags help jobs quit].include?(result) ? result : nil
|
|
206
257
|
end
|
|
207
258
|
|
|
208
259
|
def toggle_hidden
|
|
@@ -237,8 +288,6 @@ module Rakpak
|
|
|
237
288
|
|
|
238
289
|
def page = @page_size || 10
|
|
239
290
|
|
|
240
|
-
# -------------------------------------------------------------- drawing
|
|
241
|
-
|
|
242
291
|
def draw(screen, status_line)
|
|
243
292
|
@page_size = screen.h - 5
|
|
244
293
|
draw_header(screen)
|
|
@@ -249,8 +298,6 @@ module Rakpak
|
|
|
249
298
|
draw_footer(screen, screen.h - 1, status_line)
|
|
250
299
|
end
|
|
251
300
|
|
|
252
|
-
# Left to right: the queue (when shown), parent, current, preview. The
|
|
253
|
-
# queue takes its slice first and the usual three share what is left.
|
|
254
301
|
def layout(w)
|
|
255
302
|
panes = []
|
|
256
303
|
x = 0
|
|
@@ -280,7 +327,10 @@ module Rakpak
|
|
|
280
327
|
def draw_header(screen)
|
|
281
328
|
screen.fill(0, 0, screen.w, 1, " ", Theme::HEAD)
|
|
282
329
|
screen.put(1, 0, "rakpak", Theme::HEAD + "\e[1;38;5;39m")
|
|
283
|
-
right =
|
|
330
|
+
right = if @scope then "marking exclusions"
|
|
331
|
+
elsif @excludes.any? then "#{tag_summary} · #{@excludes.size} excluded"
|
|
332
|
+
else tag_summary
|
|
333
|
+
end
|
|
284
334
|
avail = screen.w - 10 - Text.width(right) - 3
|
|
285
335
|
screen.put(9, 0, Text.fit_left(Text.tilde(@cwd), [avail, 1].max), Theme::HEAD + "\e[38;5;231m")
|
|
286
336
|
screen.put(screen.w - Text.width(right) - 1, 0, right,
|
|
@@ -308,9 +358,6 @@ module Rakpak
|
|
|
308
358
|
end
|
|
309
359
|
end
|
|
310
360
|
|
|
311
|
-
# Everything queued for the next pack, wherever on the disk it lives:
|
|
312
|
-
# the name, then where it is, with the size at the edge and the running
|
|
313
|
-
# total on top. Stays put while you keep browsing.
|
|
314
361
|
def draw_queue(screen, pane, y, h)
|
|
315
362
|
x = pane[:x]
|
|
316
363
|
w = pane[:w]
|
|
@@ -362,12 +409,16 @@ module Rakpak
|
|
|
362
409
|
row = y + i
|
|
363
410
|
sel = (top + i) == cur
|
|
364
411
|
tagged = @tags.include?(e.path)
|
|
412
|
+
excluded = @excludes.include?(e.path)
|
|
365
413
|
bg = if sel && active then Theme::CUR_BG
|
|
366
414
|
elsif sel then Theme::SEL_BG
|
|
367
415
|
end
|
|
368
416
|
screen.fill(x, row, w, 1, " ", bg) if bg
|
|
369
|
-
mark_style = tagged
|
|
370
|
-
|
|
417
|
+
mark, mark_style = if tagged then [" ▌", Theme::TAG]
|
|
418
|
+
elsif excluded then [" ✗", Theme::ERR]
|
|
419
|
+
else [" ", Theme::FAINT]
|
|
420
|
+
end
|
|
421
|
+
screen.put(x, row, mark, bg.to_s + mark_style)
|
|
371
422
|
name_style = bg ? bg + (sel && active ? "\e[1;38;5;231m" : e.style) : e.style
|
|
372
423
|
avail = w - 3
|
|
373
424
|
avail -= 5 unless pane[:kind] == :parent
|
|
@@ -422,8 +473,7 @@ module Rakpak
|
|
|
422
473
|
end
|
|
423
474
|
|
|
424
475
|
def file_preview(entry, w, h)
|
|
425
|
-
# Reading a FIFO or
|
|
426
|
-
# would freeze the UI with the cursor merely resting on the entry.
|
|
476
|
+
# Reading a FIFO or device blocks and would freeze the UI.
|
|
427
477
|
kind = entry.target_stat
|
|
428
478
|
return [["not a regular file", Theme::FAINT]] unless kind&.file?
|
|
429
479
|
|
|
@@ -443,7 +493,6 @@ module Rakpak
|
|
|
443
493
|
[[e.class.name.split("::").last, Theme::ERR]]
|
|
444
494
|
end
|
|
445
495
|
|
|
446
|
-
# Bytes per row adapts to the pane so the ASCII column always fits.
|
|
447
496
|
def hexdump(bytes, rows, width)
|
|
448
497
|
return [] if rows <= 0
|
|
449
498
|
|
|
@@ -487,7 +536,9 @@ module Rakpak
|
|
|
487
536
|
end
|
|
488
537
|
|
|
489
538
|
HINTS = [["space", "tag"], ["←→", "nav"], ["a", "all"], ["t", "queue"], ["/", "find"],
|
|
490
|
-
[".", "hidden"], ["p", "pack"], ["?", "help"]].freeze
|
|
539
|
+
[".", "hidden"], ["p", "pack"], ["u", "unpack"], ["?", "help"]].freeze
|
|
540
|
+
SCOPE_HINTS = [["space", "exclude"], ["←→", "nav"], ["/", "find"], [".", "hidden"],
|
|
541
|
+
["p", "back to pack"], ["?", "help"]].freeze
|
|
491
542
|
|
|
492
543
|
def draw_footer(screen, y, status_line)
|
|
493
544
|
screen.fill(0, y, screen.w, 1, " ", nil)
|
|
@@ -496,7 +547,7 @@ module Rakpak
|
|
|
496
547
|
return
|
|
497
548
|
end
|
|
498
549
|
x = 1
|
|
499
|
-
HINTS.each do |k, label|
|
|
550
|
+
(@scope ? SCOPE_HINTS : HINTS).each do |k, label|
|
|
500
551
|
break if x + Text.width(k) + Text.width(label) + 3 > screen.w
|
|
501
552
|
|
|
502
553
|
x = screen.put(x, y, k, Theme::KEY)
|
data/lib/rakpak/dir_cache.rb
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
require_relative "entry"
|
|
4
4
|
|
|
5
5
|
module Rakpak
|
|
6
|
-
# Directory listings, memoised per (path, show_hidden). Cheap enough that
|
|
7
|
-
# the parent/preview panes can ask on every keystroke.
|
|
8
6
|
class DirCache
|
|
9
7
|
MAX_ENTRIES = 20_000
|
|
10
8
|
|
|
@@ -31,7 +29,7 @@ module Rakpak
|
|
|
31
29
|
names.reject! { |n| n.start_with?(".") } unless hidden
|
|
32
30
|
names = names.first(MAX_ENTRIES)
|
|
33
31
|
names.map { |n| Entry.new(File.join(path, n), n) }.sort_by(&:sort_key)
|
|
34
|
-
rescue StandardError
|
|
32
|
+
rescue StandardError
|
|
35
33
|
nil
|
|
36
34
|
end
|
|
37
35
|
end
|
data/lib/rakpak/entry.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rakpak
|
|
4
|
-
# One filesystem row. Stats are taken once, lazily, and never raise.
|
|
5
4
|
class Entry
|
|
6
5
|
attr_reader :path, :name
|
|
7
6
|
|
|
@@ -64,13 +63,11 @@ module Rakpak
|
|
|
64
63
|
Theme::NORMAL
|
|
65
64
|
end
|
|
66
65
|
|
|
67
|
-
#
|
|
68
|
-
# valid UTF-8 replaced so downcase cannot raise on them.
|
|
66
|
+
# Scrub invalid UTF-8 first, or downcase raises.
|
|
69
67
|
def fold
|
|
70
68
|
@fold ||= (@name.valid_encoding? ? @name : @name.scrub("?")).downcase
|
|
71
69
|
end
|
|
72
70
|
|
|
73
|
-
# Dirs first, then case-insensitive natural order.
|
|
74
71
|
def sort_key
|
|
75
72
|
[dir? ? 0 : 1, fold, @name]
|
|
76
73
|
end
|
data/lib/rakpak/formats.rb
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
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
4
|
module Tools
|
|
8
5
|
module_function
|
|
9
6
|
|
|
@@ -27,8 +24,7 @@ module Rakpak
|
|
|
27
24
|
@zip_bz2 = false
|
|
28
25
|
end
|
|
29
26
|
|
|
30
|
-
# GNU tar, bsdtar (macOS) and busybox tar disagree about flags.
|
|
31
|
-
# once so the UI only offers switches this tar actually understands.
|
|
27
|
+
# GNU tar, bsdtar (macOS) and busybox tar disagree about flags.
|
|
32
28
|
def tar_flavor
|
|
33
29
|
return @tar_flavor if defined?(@tar_flavor)
|
|
34
30
|
|
|
@@ -54,7 +50,6 @@ module Rakpak
|
|
|
54
50
|
|
|
55
51
|
end
|
|
56
52
|
|
|
57
|
-
# A tar compression backend.
|
|
58
53
|
Codec = Struct.new(:id, :label, :ext, :bin, :levels, :default, :threads, :blurb,
|
|
59
54
|
:level_opt, keyword_init: true) do
|
|
60
55
|
def available? = bin.nil? || Tools.available?(bin)
|
|
@@ -62,10 +57,8 @@ module Rakpak
|
|
|
62
57
|
def why_not = available? ? nil : "#{bin} not installed"
|
|
63
58
|
def container? = false
|
|
64
59
|
|
|
65
|
-
# ".tar.gz" for a tarball, ".gz" when compressing a lone file.
|
|
66
60
|
def single_ext = ext.delete_prefix(".tar")
|
|
67
61
|
|
|
68
|
-
# The compressor reading a named file and writing to stdout.
|
|
69
62
|
def argv(level)
|
|
70
63
|
return nil if bin.nil?
|
|
71
64
|
|
|
@@ -76,8 +69,7 @@ module Rakpak
|
|
|
76
69
|
parts
|
|
77
70
|
end
|
|
78
71
|
|
|
79
|
-
# tar
|
|
80
|
-
# The long form is understood by both GNU tar and bsdtar.
|
|
72
|
+
# tar splits --use-compress-program on spaces itself; GNU tar and bsdtar both accept it.
|
|
81
73
|
def filter(level) = argv(level)&.join(" ")
|
|
82
74
|
end
|
|
83
75
|
|
|
@@ -102,7 +94,6 @@ module Rakpak
|
|
|
102
94
|
|
|
103
95
|
def self.tar_codec(id) = TAR_CODECS.find { |c| c.id == id }
|
|
104
96
|
|
|
105
|
-
# A zip container written by Info-ZIP, with one of its entry methods.
|
|
106
97
|
ZipMethod = Struct.new(:id, :label, :flag, :levels, :default, :blurb, keyword_init: true) do
|
|
107
98
|
def available?
|
|
108
99
|
flag == "bzip2" ? Tools.zip_has_bzip2? : Tools.available?("zip")
|
|
@@ -127,15 +118,11 @@ module Rakpak
|
|
|
127
118
|
blurb: ".zip with no compression")
|
|
128
119
|
].freeze
|
|
129
120
|
|
|
130
|
-
# Everything the zip target can produce: a .zip of anything, or a lone
|
|
131
|
-
# file run through one compressor (notes.txt.gz).
|
|
132
121
|
COMPRESSORS = (ZIP_METHODS + TAR_CODECS.reject { |c| c.id == :none }).freeze
|
|
133
122
|
|
|
134
123
|
def self.compressor(id) = COMPRESSORS.find { |c| c.id == id }
|
|
135
124
|
|
|
136
|
-
# Toggleable switches, rendered as a checklist.
|
|
137
125
|
Flag = Struct.new(:id, :label, :args, :on, :blurb, :needs, keyword_init: true) do
|
|
138
|
-
# `needs` lists the tar flavours that understand this switch.
|
|
139
126
|
def supported?(flavor) = needs.nil? || needs.include?(flavor)
|
|
140
127
|
end
|
|
141
128
|
|
|
@@ -143,7 +130,6 @@ module Rakpak
|
|
|
143
130
|
BOTH = %i[gnu bsd unknown].freeze
|
|
144
131
|
ALL = %i[gnu bsd busybox unknown].freeze
|
|
145
132
|
|
|
146
|
-
# Only flags this machine's tar understands are offered.
|
|
147
133
|
def self.tar_flags(flavor = Tools.tar_flavor)
|
|
148
134
|
[
|
|
149
135
|
Flag.new(id: :verbose, label: "verbose", args: ["-v"], on: true, needs: ALL,
|
data/lib/rakpak/job.rb
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
require_relative "plan"
|
|
4
4
|
|
|
5
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
6
|
class Job
|
|
9
7
|
KEEP = 400
|
|
10
8
|
|
|
@@ -24,7 +22,7 @@ module Rakpak
|
|
|
24
22
|
@verbose = true
|
|
25
23
|
@cancel = false
|
|
26
24
|
@pid = nil
|
|
27
|
-
@spawned = nil
|
|
25
|
+
@spawned = nil
|
|
28
26
|
@started_at = nil
|
|
29
27
|
@finished_at = nil
|
|
30
28
|
end
|
|
@@ -57,8 +55,6 @@ module Rakpak
|
|
|
57
55
|
|
|
58
56
|
def total_files = @total_files
|
|
59
57
|
|
|
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
58
|
def fraction
|
|
63
59
|
return nil unless @total_files&.positive? && @verbose
|
|
64
60
|
|
|
@@ -67,16 +63,21 @@ module Rakpak
|
|
|
67
63
|
[base + (per * [@file_count.to_f / @total_files, 1.0].min), 1.0].min
|
|
68
64
|
end
|
|
69
65
|
|
|
70
|
-
#
|
|
66
|
+
# A directory's own size means nothing mid-extraction.
|
|
71
67
|
def output_size
|
|
72
|
-
out =
|
|
73
|
-
return nil
|
|
68
|
+
out = writing_now
|
|
69
|
+
return nil if out.nil? || File.directory?(out)
|
|
74
70
|
|
|
75
71
|
File.size(out)
|
|
76
72
|
rescue StandardError
|
|
77
73
|
nil
|
|
78
74
|
end
|
|
79
75
|
|
|
76
|
+
def writing_now
|
|
77
|
+
i = [@step_index, @steps.size - 1].min
|
|
78
|
+
@steps[i]&.[](3) || @plan.outputs[[i, @plan.outputs.size - 1].min]
|
|
79
|
+
end
|
|
80
|
+
|
|
80
81
|
def summary
|
|
81
82
|
@plan.outputs.map do |o|
|
|
82
83
|
size = begin
|
|
@@ -93,7 +94,6 @@ module Rakpak
|
|
|
93
94
|
kill_current
|
|
94
95
|
end
|
|
95
96
|
|
|
96
|
-
# Blocks until the worker is finished, for at most `secs`.
|
|
97
97
|
def wait(secs = nil) = @thread&.join(secs)
|
|
98
98
|
|
|
99
99
|
private
|
|
@@ -105,7 +105,7 @@ module Rakpak
|
|
|
105
105
|
return unless pid
|
|
106
106
|
|
|
107
107
|
begin
|
|
108
|
-
Process.kill("TERM", -pid) #
|
|
108
|
+
Process.kill("TERM", -pid) # whole group: tar and its compressor
|
|
109
109
|
rescue StandardError
|
|
110
110
|
nil
|
|
111
111
|
end
|
|
@@ -132,11 +132,12 @@ module Rakpak
|
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
def run_all
|
|
135
|
+
@plan.prepare
|
|
135
136
|
@steps.each_with_index do |(label, argv, verbose, stdout), idx|
|
|
136
137
|
@step_index = idx
|
|
137
138
|
@file_count = 0
|
|
138
139
|
@verbose = verbose
|
|
139
|
-
# A cancel
|
|
140
|
+
# A cancel between steps must not start the next one.
|
|
140
141
|
return finish(:cancelled) if @cancel
|
|
141
142
|
|
|
142
143
|
push("▸ #{label}: #{Plan.show_cmd(argv, stdout)}")
|
|
@@ -149,6 +150,7 @@ module Rakpak
|
|
|
149
150
|
end
|
|
150
151
|
@spawned = nil
|
|
151
152
|
end
|
|
153
|
+
@plan.commit
|
|
152
154
|
finish(:done)
|
|
153
155
|
rescue StandardError => e
|
|
154
156
|
@error = "#{e.class}: #{e.message}"
|
|
@@ -156,8 +158,7 @@ module Rakpak
|
|
|
156
158
|
end
|
|
157
159
|
|
|
158
160
|
def failure_message(label, status)
|
|
159
|
-
#
|
|
160
|
-
# case run_step already recorded the useful message.
|
|
161
|
+
# nil status: spawn failed and run_step already set @error.
|
|
161
162
|
return @error || "#{label} could not be started" if status.nil?
|
|
162
163
|
|
|
163
164
|
if status.signaled?
|
|
@@ -169,16 +170,19 @@ module Rakpak
|
|
|
169
170
|
end
|
|
170
171
|
|
|
171
172
|
def finish(state)
|
|
172
|
-
|
|
173
|
+
if %i[failed cancelled].include?(state)
|
|
174
|
+
cleanup_incomplete
|
|
175
|
+
@plan.rollback
|
|
176
|
+
end
|
|
173
177
|
@state = state
|
|
174
178
|
@finished_at = now
|
|
175
179
|
end
|
|
176
180
|
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
# was actually interrupted. Archives finished by earlier steps are whole
|
|
180
|
-
# and must survive, and a step that never spawned wrote nothing.
|
|
181
|
+
# Remove only the interrupted step's half-written archive; earlier ones are whole.
|
|
182
|
+
# Extraction output may hold pre-existing files, so it is never removed.
|
|
181
183
|
def cleanup_incomplete
|
|
184
|
+
return unless @plan.clobbers_output?
|
|
185
|
+
|
|
182
186
|
path = @spawned
|
|
183
187
|
return unless path && File.exist?(path)
|
|
184
188
|
|
|
@@ -188,28 +192,28 @@ module Rakpak
|
|
|
188
192
|
push("could not remove #{File.basename(path)}: #{e.message}")
|
|
189
193
|
end
|
|
190
194
|
|
|
191
|
-
# `stdout` names a file the command's output is the archive for (gzip -c);
|
|
192
|
-
# otherwise stdout joins stderr in the log.
|
|
193
195
|
def run_step(argv, stdout = nil)
|
|
194
|
-
out = @plan.outputs[@step_index]
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
# otherwise update it in place, keeping members that no longer exist.
|
|
198
|
-
File.unlink(out) if out && File.exist?(out)
|
|
196
|
+
out = stdout || @plan.outputs[@step_index]
|
|
197
|
+
# zip would otherwise update an existing archive in place, keeping stale members.
|
|
198
|
+
clear_path(out) if @plan.clobbers_output?
|
|
199
199
|
@spawned = out
|
|
200
200
|
|
|
201
201
|
rd, wr = IO.pipe
|
|
202
|
+
sink = nil
|
|
202
203
|
begin
|
|
203
|
-
|
|
204
|
+
sink = open_sink(stdout) if stdout
|
|
205
|
+
pid = Process.spawn(*argv, out: sink || wr, err: wr, in: File::NULL,
|
|
204
206
|
pgroup: true, chdir: @plan.base)
|
|
205
207
|
rescue StandardError => e
|
|
206
208
|
# Nothing was spawned, so nothing will close these for us.
|
|
207
209
|
rd.close
|
|
208
210
|
wr.close
|
|
211
|
+
sink&.close
|
|
209
212
|
@error = e.message
|
|
210
213
|
return nil
|
|
211
214
|
end
|
|
212
215
|
wr.close
|
|
216
|
+
sink&.close
|
|
213
217
|
@pid = pid
|
|
214
218
|
# A cancel that raced the spawn saw no pid to signal; do it now.
|
|
215
219
|
kill_current if @cancel
|
|
@@ -225,8 +229,7 @@ module Rakpak
|
|
|
225
229
|
rescue EOFError
|
|
226
230
|
break
|
|
227
231
|
end
|
|
228
|
-
#
|
|
229
|
-
# has to be made printable before it can meet the UTF-8 frame.
|
|
232
|
+
# Filenames are arbitrary bytes; scrub before they meet the UTF-8 frame.
|
|
230
233
|
buf << chunk.force_encoding(Encoding::UTF_8).scrub("·")
|
|
231
234
|
# tar emits newlines, zip rewrites lines with \r.
|
|
232
235
|
while (m = buf.match(/[\r\n]/))
|
|
@@ -244,6 +247,25 @@ module Rakpak
|
|
|
244
247
|
status
|
|
245
248
|
end
|
|
246
249
|
|
|
250
|
+
# lstat: a dangling symlink is not exist?, and writing through it would escape the folder.
|
|
251
|
+
def clear_path(path)
|
|
252
|
+
return unless path
|
|
253
|
+
|
|
254
|
+
st = begin
|
|
255
|
+
File.lstat(path)
|
|
256
|
+
rescue StandardError
|
|
257
|
+
return
|
|
258
|
+
end
|
|
259
|
+
File.unlink(path) unless st.directory?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# O_EXCL|O_NOFOLLOW: never write through a symlink planted after clear_path.
|
|
263
|
+
SINK_FLAGS = File::WRONLY | File::CREAT | File::EXCL |
|
|
264
|
+
(defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
|
|
265
|
+
|
|
266
|
+
# 0666 so the result respects umask, like a shell redirect.
|
|
267
|
+
def open_sink(path) = File.open(path, SINK_FLAGS, 0o666)
|
|
268
|
+
|
|
247
269
|
def record(line)
|
|
248
270
|
return if line.empty?
|
|
249
271
|
|