howzit 2.1.41 → 2.1.44
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/CHANGELOG.md +64 -0
- data/Rakefile +3 -0
- data/howzit.gemspec +1 -1
- data/lib/howzit/buildnote.rb +175 -33
- data/lib/howzit/task.rb +2 -2
- data/lib/howzit/topic.rb +154 -96
- data/lib/howzit/util.rb +13 -26
- data/lib/howzit/version.rb +1 -1
- data/spec/cli_spec.rb +1 -0
- data/spec/includes_metadata_spec.rb +124 -0
- data/spec/spec_helper.rb +0 -1
- data/spec/subtopics_spec.rb +139 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 881188642b99f4be83a7697440251ec8890e6d847468c3984a72183939196857
|
|
4
|
+
data.tar.gz: c622b23318a075aa277a0a197e1ec93d8023e52e99018557e7eb6f8309c76927
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 219ae789410d1ec44619f3bfb00b7f8b539a33632e9b9d9e778f384205e03c653efa83a7a090d469dd769a2cdda53ae7026e40b495363879e59f24be38da09ae
|
|
7
|
+
data.tar.gz: d0c1dd2d03ab11923e93440fb9f04bfdfb0292d63522fd21483a38a7d2ca31c691dc64e4a894063944b586ef1b2af66fd8a4bd55ea132ce743b6e69e7fd812db
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
+
### 2.1.44
|
|
2
|
+
|
|
3
|
+
2026-09-27 11:38
|
|
4
|
+
|
|
5
|
+
#### CHANGED
|
|
6
|
+
|
|
7
|
+
- **Task failures** in a parent or subtopic stop the remaining subtopics from running unless --force is used.
|
|
8
|
+
- **@after blocks** on a parent are shown after the parent and all of its subtopics finish.
|
|
9
|
+
|
|
10
|
+
#### NEW
|
|
11
|
+
|
|
12
|
+
- **Subtopics**: deeper headers (### under ##, #### under ###, etc.) are now nested under the preceding shallower topic.
|
|
13
|
+
- **Displaying a parent topic** shows all of its subtopics after it, with subtopic headers drawn in a lighter style (cyan title, dotted rule).
|
|
14
|
+
- **Running a parent topic** runs its own tasks, then every subtopic's tasks in order (depth-first), with a single combined "Ran N tasks" summary.
|
|
15
|
+
|
|
16
|
+
#### IMPROVED
|
|
17
|
+
|
|
18
|
+
- **Showing all topics** (howzit with no arguments, or howzit -r with no default) only outputs top-level topics so subtopics are not repeated.
|
|
19
|
+
- **Selecting both a parent and its subtopic** outputs/runs the subtopic only once, as part of the parent.
|
|
20
|
+
- **Topic lists** (howzit -l and the runnable topics list) indent subtopics under their parents.
|
|
21
|
+
- **@include(Topic)** on a parent runs the parent and all of its subtopics, and task counts in include notes and menus now count subtopic tasks.
|
|
22
|
+
|
|
23
|
+
#### FIXED
|
|
24
|
+
|
|
25
|
+
- **Parent topics with no tasks of their own** no longer warn "No @directive found" when their subtopics contain tasks.
|
|
26
|
+
|
|
27
|
+
### 2.1.43
|
|
28
|
+
|
|
29
|
+
2026-09-27 11:35
|
|
30
|
+
|
|
31
|
+
#### CHANGED
|
|
32
|
+
|
|
33
|
+
- **Task failures** in a parent or subtopic stop the remaining subtopics from running unless --force is used.
|
|
34
|
+
- **@after blocks** on a parent are shown after the parent and all of its subtopics finish.
|
|
35
|
+
|
|
36
|
+
#### NEW
|
|
37
|
+
|
|
38
|
+
- **Subtopics**: deeper headers (### under ##, #### under ###, etc.) are now nested under the preceding shallower topic.
|
|
39
|
+
- **Displaying a parent topic** shows all of its subtopics after it, with subtopic headers drawn in a lighter style (cyan title, dotted rule).
|
|
40
|
+
- **Running a parent topic** runs its own tasks, then every subtopic's tasks in order (depth-first), with a single combined "Ran N tasks" summary.
|
|
41
|
+
|
|
42
|
+
#### IMPROVED
|
|
43
|
+
|
|
44
|
+
- **Showing all topics** (howzit with no arguments, or howzit -r with no default) only outputs top-level topics so subtopics are not repeated.
|
|
45
|
+
- **Selecting both a parent and its subtopic** outputs/runs the subtopic only once, as part of the parent.
|
|
46
|
+
- **Topic lists** (howzit -l and the runnable topics list) indent subtopics under their parents.
|
|
47
|
+
- **@include(Topic)** on a parent runs the parent and all of its subtopics, and task counts in include notes and menus now count subtopic tasks.
|
|
48
|
+
|
|
49
|
+
#### FIXED
|
|
50
|
+
|
|
51
|
+
- **Parent topics with no tasks of their own** no longer warn "No @directive found" when their subtopics contain tasks.
|
|
52
|
+
|
|
53
|
+
### 2.1.42
|
|
54
|
+
|
|
55
|
+
2026-06-29 06:52
|
|
56
|
+
|
|
57
|
+
#### NEW
|
|
58
|
+
|
|
59
|
+
- `includes:` metadata to pull topics from external build note files or project directories (comma-separated paths, optional `[topic | filter]` like templates); directory paths auto-detect the build note; included topics are prefixed by basename; local topics always win on duplicate names.
|
|
60
|
+
|
|
61
|
+
#### FIXED
|
|
62
|
+
|
|
63
|
+
- Paged build note output no longer opens with the first line scrolled off screen (pager receives the full buffer via IO.popen instead of starting on partial pipe input).
|
|
64
|
+
|
|
1
65
|
### 2.1.41
|
|
2
66
|
|
|
3
67
|
2026-05-10 08:18
|
data/Rakefile
CHANGED
data/howzit.gemspec
CHANGED
|
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
|
|
|
25
25
|
|
|
26
26
|
spec.required_ruby_version = '>= 2.6.0'
|
|
27
27
|
|
|
28
|
-
spec.add_development_dependency 'bundler', '
|
|
28
|
+
spec.add_development_dependency 'bundler', '>= 2.2'
|
|
29
29
|
spec.add_development_dependency 'rake', '~> 13.0'
|
|
30
30
|
|
|
31
31
|
spec.add_development_dependency 'guard', '~> 2.11'
|
data/lib/howzit/buildnote.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
3
5
|
module Howzit
|
|
4
6
|
# BuildNote Class
|
|
5
7
|
class BuildNote
|
|
@@ -12,9 +14,11 @@ module Howzit
|
|
|
12
14
|
##
|
|
13
15
|
## @param file [String] The path to the build note file
|
|
14
16
|
##
|
|
15
|
-
def initialize(file: nil, meta: nil)
|
|
17
|
+
def initialize(file: nil, meta: nil, skip_includes: false, included_paths: nil)
|
|
16
18
|
# Track if an explicit file was provided
|
|
17
19
|
@explicit_file = file ? File.expand_path(file) : nil
|
|
20
|
+
@skip_includes = skip_includes
|
|
21
|
+
@included_paths = included_paths || Set.new
|
|
18
22
|
|
|
19
23
|
# Set @note_file if an explicit file was provided, before calling note_file getter
|
|
20
24
|
if file
|
|
@@ -165,11 +169,37 @@ module Howzit
|
|
|
165
169
|
output = []
|
|
166
170
|
output.push("{bg}Topics:{x}\n".c)
|
|
167
171
|
@topics.each do |topic|
|
|
168
|
-
output.push("- {bw}#{topic.title}{x}".c)
|
|
172
|
+
output.push("#{subtopic_indent(topic)}- {bw}#{topic.title}{x}".c)
|
|
169
173
|
end
|
|
170
174
|
output.join("\n")
|
|
171
175
|
end
|
|
172
176
|
|
|
177
|
+
##
|
|
178
|
+
## Indentation for a topic in list output, based on
|
|
179
|
+
## how many of its parent topics are in the list
|
|
180
|
+
##
|
|
181
|
+
## @param topic [Topic] The topic
|
|
182
|
+
##
|
|
183
|
+
## @return [String] leading whitespace
|
|
184
|
+
##
|
|
185
|
+
def subtopic_indent(topic)
|
|
186
|
+
' ' * topic.ancestors.count { |parent| @topics.any? { |t| t.equal?(parent) } }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
##
|
|
190
|
+
## Topics that aren't nested under another topic in
|
|
191
|
+
## the build note (subtopics are output with their parent)
|
|
192
|
+
##
|
|
193
|
+
## @param topics [Array] Topics to filter, defaults to all topics
|
|
194
|
+
##
|
|
195
|
+
## @return [Array] array of Topics
|
|
196
|
+
##
|
|
197
|
+
def top_level_topics(topics = @topics)
|
|
198
|
+
topics.reject do |topic|
|
|
199
|
+
topic.ancestors.any? { |parent| topics.any? { |t| t.equal?(parent) } }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
173
203
|
##
|
|
174
204
|
## Return an array of topic titles
|
|
175
205
|
##
|
|
@@ -200,7 +230,7 @@ module Howzit
|
|
|
200
230
|
def list_runnable_completions
|
|
201
231
|
output = []
|
|
202
232
|
@topics.each do |topic|
|
|
203
|
-
next unless topic.
|
|
233
|
+
next unless topic.all_tasks.any?
|
|
204
234
|
|
|
205
235
|
output.push(topic.title)
|
|
206
236
|
end
|
|
@@ -218,11 +248,10 @@ module Howzit
|
|
|
218
248
|
output.push(%({bg}"Runnable" Topics:{x}\n).c)
|
|
219
249
|
|
|
220
250
|
find_topic(Howzit.options[:for_topic]).each do |topic|
|
|
221
|
-
|
|
251
|
+
next if topic.all_tasks.empty?
|
|
222
252
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
next if s_out.empty?
|
|
253
|
+
indent = subtopic_indent(topic)
|
|
254
|
+
s_out = topic.tasks.map { |task| "#{indent}#{task.to_list}" }
|
|
226
255
|
|
|
227
256
|
title = topic.title
|
|
228
257
|
# Show argument definitions with colorized formatting
|
|
@@ -231,8 +260,8 @@ module Howzit
|
|
|
231
260
|
title += " {l}({x}#{formatted_args}{l}){x}".c
|
|
232
261
|
end
|
|
233
262
|
|
|
234
|
-
output.push("- {g}#{title}{x}".c)
|
|
235
|
-
output.push(s_out.join("\n"))
|
|
263
|
+
output.push("#{indent}- {g}#{title}{x}".c)
|
|
264
|
+
output.push(s_out.join("\n")) unless s_out.empty?
|
|
236
265
|
end
|
|
237
266
|
|
|
238
267
|
output.join("\n")
|
|
@@ -653,7 +682,7 @@ module Howzit
|
|
|
653
682
|
## @return [Array] extracted topics
|
|
654
683
|
##
|
|
655
684
|
def read_template(template, file, subtopics = nil)
|
|
656
|
-
note = BuildNote.new(file: file, meta: @metadata)
|
|
685
|
+
note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
|
|
657
686
|
|
|
658
687
|
template_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
|
|
659
688
|
template_topics.map do |topic|
|
|
@@ -751,6 +780,24 @@ module Howzit
|
|
|
751
780
|
files.min
|
|
752
781
|
end
|
|
753
782
|
|
|
783
|
+
##
|
|
784
|
+
## Glob a directory for a valid build note filename
|
|
785
|
+
##
|
|
786
|
+
## @param dir [String] Directory to search
|
|
787
|
+
##
|
|
788
|
+
## @return [String, nil] Absolute path to build note file
|
|
789
|
+
##
|
|
790
|
+
def glob_note_in(dir)
|
|
791
|
+
pattern = File.join(File.expand_path(dir), '*.{txt,md,markdown}')
|
|
792
|
+
files = Dir.glob(pattern).select { |f| File.basename(f).build_note? }
|
|
793
|
+
return nil if files.empty?
|
|
794
|
+
|
|
795
|
+
priority_files = files.select { |f| File.basename(f) =~ /^(buildnotes|howzit)\./i }
|
|
796
|
+
return priority_files.min unless priority_files.empty?
|
|
797
|
+
|
|
798
|
+
files.min
|
|
799
|
+
end
|
|
800
|
+
|
|
754
801
|
##
|
|
755
802
|
## Search for a valid build note, checking current
|
|
756
803
|
## directory, git top level directory, and parent
|
|
@@ -794,26 +841,117 @@ module Howzit
|
|
|
794
841
|
end
|
|
795
842
|
|
|
796
843
|
##
|
|
797
|
-
## Read a list of topics from
|
|
844
|
+
## Read a list of topics from included templates and includes: metadata
|
|
798
845
|
##
|
|
799
|
-
## @param content [String] The
|
|
846
|
+
## @param content [String] The build note contents
|
|
800
847
|
##
|
|
801
848
|
def get_template_topics(content)
|
|
802
849
|
leader = content.split(/^#/)[0].strip
|
|
803
850
|
|
|
804
|
-
|
|
851
|
+
external_topics = []
|
|
805
852
|
|
|
806
|
-
return
|
|
853
|
+
return external_topics if leader.empty?
|
|
807
854
|
|
|
808
855
|
data = leader.metadata
|
|
809
856
|
|
|
810
857
|
if data.key?('template')
|
|
811
858
|
templates = data['template'].strip.split(/\s*,\s*/)
|
|
812
859
|
|
|
813
|
-
|
|
860
|
+
external_topics.concat(gather_templates(templates))
|
|
814
861
|
end
|
|
815
862
|
|
|
816
|
-
|
|
863
|
+
if data.key?('includes') && !@skip_includes
|
|
864
|
+
includes = data['includes'].strip.split(/\s*,\s*/)
|
|
865
|
+
|
|
866
|
+
external_topics.concat(gather_includes(includes))
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
external_topics
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
##
|
|
873
|
+
## Resolve an includes: metadata entry to a label and build note file path
|
|
874
|
+
##
|
|
875
|
+
## @param path_spec [String] File path, directory, or ~ path
|
|
876
|
+
##
|
|
877
|
+
## @return [Array, nil] [label, absolute_file_path] or nil if not found
|
|
878
|
+
##
|
|
879
|
+
def resolve_include_path(path_spec)
|
|
880
|
+
path = File.expand_path(path_spec.to_s.strip)
|
|
881
|
+
return nil unless File.exist?(path)
|
|
882
|
+
|
|
883
|
+
if File.directory?(path)
|
|
884
|
+
note_path = glob_note_in(path)
|
|
885
|
+
return nil unless note_path
|
|
886
|
+
|
|
887
|
+
[File.basename(path), File.expand_path(note_path)]
|
|
888
|
+
elsif File.file?(path)
|
|
889
|
+
[File.basename(path, File.extname(path)), path]
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
##
|
|
894
|
+
## Read topics from an external build note referenced by includes: metadata
|
|
895
|
+
##
|
|
896
|
+
def read_include(label, file, subtopics = nil)
|
|
897
|
+
note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
|
|
898
|
+
included_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
|
|
899
|
+
included_topics.map do |topic|
|
|
900
|
+
topic.parent = label
|
|
901
|
+
topic.content = topic.content.render_template(@metadata)
|
|
902
|
+
topic.instance_variable_set(:@title, "#{label}:#{topic.title}") unless topic.title.include?(':')
|
|
903
|
+
topic
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
##
|
|
908
|
+
## Load topics from includes: metadata paths (comma-separated)
|
|
909
|
+
##
|
|
910
|
+
def gather_includes(includes)
|
|
911
|
+
included_topics = []
|
|
912
|
+
|
|
913
|
+
includes.each do |spec|
|
|
914
|
+
spec = spec.strip
|
|
915
|
+
next if spec.empty?
|
|
916
|
+
|
|
917
|
+
path_spec, subtopics = detect_subtopics(spec.dup)
|
|
918
|
+
resolved = resolve_include_path(path_spec)
|
|
919
|
+
unless resolved
|
|
920
|
+
Howzit.console.warn "{br}WARNING:{xr} includes: path not found or has no build note: {bw}#{path_spec}{x}".c
|
|
921
|
+
next
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
label, file = resolved
|
|
925
|
+
abs_file = File.expand_path(file)
|
|
926
|
+
main_file = @note_file ? File.expand_path(@note_file) : nil
|
|
927
|
+
|
|
928
|
+
if main_file && abs_file == main_file
|
|
929
|
+
Howzit.console.warn "{br}WARNING:{xr} includes: skipping self-reference: {bw}#{path_spec}{x}".c
|
|
930
|
+
next
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
if @included_paths.include?(abs_file)
|
|
934
|
+
Howzit.console.warn "{br}WARNING:{xr} includes: already loaded, skipping: {bw}#{path_spec}{x}".c
|
|
935
|
+
next
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
@included_paths.add(abs_file)
|
|
939
|
+
included_topics.concat(read_include(label, abs_file, subtopics))
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
included_topics
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
##
|
|
946
|
+
## Merge external topics (templates, includes) into local topics; local wins on duplicate names
|
|
947
|
+
##
|
|
948
|
+
def merge_external_topics(local_topics, external_topics)
|
|
949
|
+
external_topics.each do |topic|
|
|
950
|
+
base = base_topic_name(topic.title)
|
|
951
|
+
exists_in_local = local_topics.any? { |t| base_topic_name(t.title) == base }
|
|
952
|
+
local_topics.push(topic) unless exists_in_local
|
|
953
|
+
end
|
|
954
|
+
local_topics
|
|
817
955
|
end
|
|
818
956
|
|
|
819
957
|
# Read in the build notes file and output a hash of
|
|
@@ -843,11 +981,13 @@ module Howzit
|
|
|
843
981
|
|
|
844
982
|
template_topics = get_template_topics(help)
|
|
845
983
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
984
|
+
sections = help.split(/^(##+)/)
|
|
985
|
+
sections.shift
|
|
986
|
+
parent_stack = []
|
|
987
|
+
sections.each_slice(2) do |marks, sect|
|
|
988
|
+
next if sect.nil? || sect.strip.empty?
|
|
850
989
|
|
|
990
|
+
level = marks.length
|
|
851
991
|
lines = sect.split(/\n/)
|
|
852
992
|
title = lines.slice!(0).strip
|
|
853
993
|
prefix = ''
|
|
@@ -865,18 +1005,17 @@ module Howzit
|
|
|
865
1005
|
# Ensure source_file is always an absolute path
|
|
866
1006
|
source_file = path || note_file
|
|
867
1007
|
source_file = File.expand_path(source_file) if source_file
|
|
868
|
-
topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata), @metadata,
|
|
1008
|
+
topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata), @metadata,
|
|
1009
|
+
source_file: source_file, level: level)
|
|
1010
|
+
|
|
1011
|
+
parent_stack.pop while parent_stack.any? && parent_stack.last.level >= level
|
|
1012
|
+
parent_stack.last&.add_subtopic(topic)
|
|
1013
|
+
parent_stack.push(topic)
|
|
869
1014
|
|
|
870
1015
|
topics.push(topic)
|
|
871
1016
|
end
|
|
872
1017
|
|
|
873
|
-
template_topics
|
|
874
|
-
# Check against local topics array, not @topics, to avoid filtering out templates
|
|
875
|
-
# when @topics already has topics (e.g., in stack mode)
|
|
876
|
-
topic_base = topic.title.sub(/^.+:/, '').strip.downcase
|
|
877
|
-
exists_in_local = topics.any? { |t| t.title.sub(/^.+:/, '').strip.downcase == topic_base }
|
|
878
|
-
topics.push(topic) unless exists_in_local
|
|
879
|
-
end
|
|
1018
|
+
merge_external_topics(topics, template_topics)
|
|
880
1019
|
|
|
881
1020
|
topics
|
|
882
1021
|
end
|
|
@@ -1266,13 +1405,13 @@ module Howzit
|
|
|
1266
1405
|
process_default_metadata(output)
|
|
1267
1406
|
else
|
|
1268
1407
|
Howzit.run_log = []
|
|
1269
|
-
Howzit.multi_topic_run =
|
|
1270
|
-
|
|
1408
|
+
Howzit.multi_topic_run = top_level_topics.length > 1
|
|
1409
|
+
top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
|
|
1271
1410
|
finalize_output(output)
|
|
1272
1411
|
end
|
|
1273
1412
|
else
|
|
1274
1413
|
# Show all topics
|
|
1275
|
-
|
|
1414
|
+
top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
|
|
1276
1415
|
finalize_output(output)
|
|
1277
1416
|
end
|
|
1278
1417
|
end
|
|
@@ -1418,9 +1557,12 @@ module Howzit
|
|
|
1418
1557
|
end
|
|
1419
1558
|
end
|
|
1420
1559
|
topic_matches.compact! # Remove any nil values from failed matches
|
|
1421
|
-
|
|
1560
|
+
# A matched subtopic is already output/run with its matched parent
|
|
1561
|
+
top_level_topics(topic_matches).each do |topic_match|
|
|
1562
|
+
output.push(process_topic(topic_match, Howzit.options[:run], single: true))
|
|
1563
|
+
end
|
|
1422
1564
|
else
|
|
1423
|
-
|
|
1565
|
+
top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
|
|
1424
1566
|
end
|
|
1425
1567
|
|
|
1426
1568
|
finalize_output(output)
|
data/lib/howzit/task.rb
CHANGED
|
@@ -152,9 +152,9 @@ module Howzit
|
|
|
152
152
|
|
|
153
153
|
Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{matches[0].title}{x}".c)
|
|
154
154
|
output.concat(matches[0].run(nested: true))
|
|
155
|
-
Howzit.console.info("{by}End include: #{matches[0].
|
|
155
|
+
Howzit.console.info("{by}End include: #{matches[0].all_tasks.count} tasks{x}".c)
|
|
156
156
|
@last_status = nil
|
|
157
|
-
[output, matches[0].
|
|
157
|
+
[output, matches[0].all_tasks.count]
|
|
158
158
|
end
|
|
159
159
|
|
|
160
160
|
##
|
data/lib/howzit/topic.rb
CHANGED
|
@@ -5,9 +5,10 @@ module Howzit
|
|
|
5
5
|
class Topic
|
|
6
6
|
attr_writer :parent
|
|
7
7
|
|
|
8
|
-
attr_accessor :content
|
|
8
|
+
attr_accessor :content, :parent_topic
|
|
9
9
|
|
|
10
|
-
attr_reader :title, :tasks, :prereqs, :postreqs, :results, :named_args, :directives, :arg_definitions, :source_file
|
|
10
|
+
attr_reader :title, :tasks, :prereqs, :postreqs, :results, :named_args, :directives, :arg_definitions, :source_file,
|
|
11
|
+
:level, :subtopics
|
|
11
12
|
|
|
12
13
|
##
|
|
13
14
|
## Initialize a topic object
|
|
@@ -16,11 +17,15 @@ module Howzit
|
|
|
16
17
|
## @param content [String] The raw topic content
|
|
17
18
|
## @param metadata [Hash] Optional metadata hash
|
|
18
19
|
## @param source_file [String] Optional path to the build note file this topic came from
|
|
20
|
+
## @param level [Integer] Markdown header level (2 for ##, 3 for ###, etc.)
|
|
19
21
|
##
|
|
20
|
-
def initialize(title, content, metadata = nil, source_file: nil)
|
|
22
|
+
def initialize(title, content, metadata = nil, source_file: nil, level: 2)
|
|
21
23
|
@title = title
|
|
22
24
|
@content = content
|
|
23
25
|
@parent = nil
|
|
26
|
+
@parent_topic = nil
|
|
27
|
+
@subtopics = []
|
|
28
|
+
@level = level
|
|
24
29
|
@nest_level = 0
|
|
25
30
|
@named_args = {}
|
|
26
31
|
@metadata = metadata
|
|
@@ -32,9 +37,44 @@ module Howzit
|
|
|
32
37
|
@results = { total: 0, success: 0, errors: 0, message: ''.c }
|
|
33
38
|
end
|
|
34
39
|
|
|
40
|
+
##
|
|
41
|
+
## Nest a topic under this one (a deeper header following this topic)
|
|
42
|
+
##
|
|
43
|
+
## @param topic [Topic] The subtopic
|
|
44
|
+
##
|
|
45
|
+
def add_subtopic(topic)
|
|
46
|
+
topic.parent_topic = self
|
|
47
|
+
@subtopics << topic
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
## Tasks from this topic and all nested subtopics, in run order
|
|
52
|
+
##
|
|
53
|
+
## @return [Array] Array of Task objects
|
|
54
|
+
##
|
|
55
|
+
def all_tasks
|
|
56
|
+
@tasks + @subtopics.flat_map(&:all_tasks)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
## Parent topics from nearest to outermost
|
|
61
|
+
##
|
|
62
|
+
## @return [Array] Array of Topic objects
|
|
63
|
+
##
|
|
64
|
+
def ancestors
|
|
65
|
+
list = []
|
|
66
|
+
node = @parent_topic
|
|
67
|
+
while node
|
|
68
|
+
list << node
|
|
69
|
+
node = node.parent_topic
|
|
70
|
+
end
|
|
71
|
+
list
|
|
72
|
+
end
|
|
73
|
+
|
|
35
74
|
# Get named arguments from title
|
|
36
|
-
# from_cli_snapshot: use Howzit.cli_topic_positional_args (
|
|
37
|
-
# cannot clobber positional binding. Re-entrant
|
|
75
|
+
# from_cli_snapshot: use Howzit.cli_topic_positional_args (argv after `--`) so earlier
|
|
76
|
+
# topics' gather_tasks cannot clobber positional binding. Re-entrant @include [a,b]
|
|
77
|
+
# calls pass false to use live Howzit.arguments.
|
|
38
78
|
def arguments(from_cli_snapshot: false)
|
|
39
79
|
@arg_definitions = []
|
|
40
80
|
return unless @title =~ /\(.*?\) *$/
|
|
@@ -79,7 +119,7 @@ module Howzit
|
|
|
79
119
|
|
|
80
120
|
def ask_task(task)
|
|
81
121
|
note = if task.type == :include
|
|
82
|
-
task_count = Howzit.buildnote.find_topic(task.action)[0].
|
|
122
|
+
task_count = Howzit.buildnote.find_topic(task.action)[0].all_tasks.count
|
|
83
123
|
" (#{task_count} tasks)"
|
|
84
124
|
else
|
|
85
125
|
''
|
|
@@ -94,80 +134,129 @@ module Howzit
|
|
|
94
134
|
60
|
|
95
135
|
end
|
|
96
136
|
|
|
97
|
-
|
|
98
|
-
|
|
137
|
+
##
|
|
138
|
+
## Handle run command, execute directives in topic, then in each subtopic
|
|
139
|
+
##
|
|
140
|
+
## @param nested [Boolean] Suppress the summary message (topic is being included)
|
|
141
|
+
## @param as_subtopic [Boolean] Topic is being run as part of a parent topic
|
|
142
|
+
##
|
|
143
|
+
## @return [Array] output lines
|
|
144
|
+
##
|
|
145
|
+
def run(nested: false, as_subtopic: false)
|
|
146
|
+
@results = { total: 0, success: 0, errors: 0, message: ''.c }
|
|
99
147
|
output = []
|
|
100
|
-
|
|
101
148
|
cols = check_cols
|
|
102
149
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
150
|
+
confirm_prereqs(cols) if !@prereqs.empty? && (all_tasks.any? || sequential?)
|
|
151
|
+
|
|
152
|
+
if sequential?
|
|
153
|
+
run_sequential(output: output)
|
|
154
|
+
elsif @tasks.any?
|
|
155
|
+
run_tasks(output)
|
|
156
|
+
elsif all_tasks.empty? && !as_subtopic && !directives?
|
|
157
|
+
Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
|
|
106
158
|
end
|
|
107
159
|
|
|
108
|
-
|
|
109
|
-
# Note: @set_var directives are already processed in gather_tasks for non-sequential path
|
|
110
|
-
# This section is kept for backward compatibility but shouldn't be needed
|
|
160
|
+
run_subtopics(output) unless halted?
|
|
111
161
|
|
|
112
|
-
if @
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
rescue Errno::EPIPE
|
|
117
|
-
# Pipe closed, ignore
|
|
118
|
-
end
|
|
119
|
-
res = Prompt.yn('Have the above prerequisites been met?', default: true)
|
|
120
|
-
Process.exit 1 unless res
|
|
162
|
+
if @results[:total].positive? || (all_tasks.any? && !sequential?)
|
|
163
|
+
@results[:message] += results_message
|
|
164
|
+
output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
|
|
165
|
+
end
|
|
121
166
|
|
|
122
|
-
|
|
167
|
+
show_postreqs(cols)
|
|
123
168
|
|
|
124
|
-
|
|
125
|
-
|
|
169
|
+
output
|
|
170
|
+
end
|
|
126
171
|
|
|
127
|
-
|
|
172
|
+
##
|
|
173
|
+
## Whether the topic contains conditional directives and must be run sequentially
|
|
174
|
+
##
|
|
175
|
+
def sequential?
|
|
176
|
+
@directives&.any?(&:conditional?) || false
|
|
177
|
+
end
|
|
128
178
|
|
|
129
|
-
|
|
130
|
-
|
|
179
|
+
##
|
|
180
|
+
## Whether this topic or any subtopic contains directives,
|
|
181
|
+
## including non-task directives like @set_var and @log_level
|
|
182
|
+
##
|
|
183
|
+
def directives?
|
|
184
|
+
@directives&.any? || @subtopics.any?(&:directives?)
|
|
185
|
+
end
|
|
131
186
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c
|
|
187
|
+
def halted?
|
|
188
|
+
@results[:errors].positive? && !Howzit.options[:force]
|
|
189
|
+
end
|
|
136
190
|
|
|
137
|
-
|
|
191
|
+
def confirm_prereqs(cols)
|
|
192
|
+
begin
|
|
193
|
+
puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
|
|
194
|
+
rescue Errno::EPIPE
|
|
195
|
+
# Pipe closed, ignore
|
|
196
|
+
end
|
|
197
|
+
res = Prompt.yn('Have the above prerequisites been met?', default: true)
|
|
198
|
+
Process.exit 1 unless res
|
|
199
|
+
end
|
|
138
200
|
|
|
139
|
-
|
|
140
|
-
|
|
201
|
+
def show_postreqs(cols)
|
|
202
|
+
return if @postreqs.empty?
|
|
141
203
|
|
|
142
|
-
|
|
143
|
-
|
|
204
|
+
# Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
|
|
205
|
+
postreqs_content = @postreqs.join("\n\n").render_arguments
|
|
206
|
+
wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
|
|
207
|
+
puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
|
|
208
|
+
rescue Errno::EPIPE
|
|
209
|
+
# Pipe closed, ignore
|
|
210
|
+
end
|
|
144
211
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
"{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
|
|
153
|
-
end
|
|
212
|
+
def results_message
|
|
213
|
+
total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
|
|
214
|
+
errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
|
|
215
|
+
if @results[:errors].zero?
|
|
216
|
+
"{bg}\u{2713} {by}Ran #{total}{x}".c
|
|
217
|
+
elsif Howzit.options[:force]
|
|
218
|
+
"{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
|
|
154
219
|
else
|
|
155
|
-
|
|
220
|
+
"{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
|
|
156
221
|
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
##
|
|
225
|
+
## Run tasks without conditional evaluation
|
|
226
|
+
##
|
|
227
|
+
def run_tasks(output)
|
|
228
|
+
@tasks.each do |task|
|
|
229
|
+
next if (task.optional || Howzit.options[:ask]) && !ask_task(task)
|
|
157
230
|
|
|
158
|
-
|
|
231
|
+
run_output, total, success = task.run
|
|
232
|
+
|
|
233
|
+
output.concat(run_output)
|
|
234
|
+
@results[:total] += total
|
|
235
|
+
|
|
236
|
+
if success
|
|
237
|
+
@results[:success] += total
|
|
238
|
+
else
|
|
239
|
+
Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c
|
|
159
240
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
postreqs_content = @postreqs.join("\n\n").render_arguments
|
|
164
|
-
wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
|
|
165
|
-
puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
|
|
166
|
-
rescue Errno::EPIPE
|
|
167
|
-
# Pipe closed, ignore
|
|
241
|
+
@results[:errors] += total
|
|
242
|
+
|
|
243
|
+
break unless Howzit.options[:force]
|
|
168
244
|
end
|
|
245
|
+
|
|
246
|
+
log_task_result(task, success)
|
|
169
247
|
end
|
|
248
|
+
output
|
|
249
|
+
end
|
|
170
250
|
|
|
251
|
+
##
|
|
252
|
+
## Run each subtopic in order, adding its results to this topic's
|
|
253
|
+
##
|
|
254
|
+
def run_subtopics(output)
|
|
255
|
+
@subtopics.each do |sub|
|
|
256
|
+
output.concat(sub.run(nested: true, as_subtopic: true))
|
|
257
|
+
%i[total success errors].each { |key| @results[key] += sub.results[key] }
|
|
258
|
+
break if halted?
|
|
259
|
+
end
|
|
171
260
|
output
|
|
172
261
|
end
|
|
173
262
|
|
|
@@ -177,7 +266,7 @@ module Howzit
|
|
|
177
266
|
end
|
|
178
267
|
|
|
179
268
|
def colored_option(color, topic, keys)
|
|
180
|
-
if topic.
|
|
269
|
+
if topic.all_tasks.empty?
|
|
181
270
|
''
|
|
182
271
|
else
|
|
183
272
|
optional = keys[:optional] =~ /[?!]+/ ? true : false
|
|
@@ -301,7 +390,8 @@ module Howzit
|
|
|
301
390
|
formatted_args = @arg_definitions.map { |arg| format_arg_definition(arg) }.join('{l}, '.c)
|
|
302
391
|
header_title += " {l}({x}#{formatted_args}{l}){x}".c
|
|
303
392
|
end
|
|
304
|
-
|
|
393
|
+
header_opts = opt[:subtopic] ? { color: '{bc}', hr: "\u{2508}" } : {}
|
|
394
|
+
output.push(header_title.format_header(header_opts))
|
|
305
395
|
output.push('')
|
|
306
396
|
end
|
|
307
397
|
# Process conditional blocks first
|
|
@@ -332,6 +422,8 @@ module Howzit
|
|
|
332
422
|
end
|
|
333
423
|
Howzit.named_arguments = @named_args
|
|
334
424
|
output.push('')
|
|
425
|
+
@subtopics.each { |sub| output.concat(sub.print_out(opt.merge(header: true, subtopic: true))) }
|
|
426
|
+
output
|
|
335
427
|
end
|
|
336
428
|
|
|
337
429
|
##
|
|
@@ -726,7 +818,7 @@ module Howzit
|
|
|
726
818
|
##
|
|
727
819
|
## Run directives sequentially with conditional re-evaluation
|
|
728
820
|
##
|
|
729
|
-
def run_sequential(
|
|
821
|
+
def run_sequential(output: [])
|
|
730
822
|
# Initialize conditional state
|
|
731
823
|
conditional_state = {} # { index => { evaluated: bool, result: bool, matched_chain: bool } }
|
|
732
824
|
directive_index = 0
|
|
@@ -736,16 +828,6 @@ module Howzit
|
|
|
736
828
|
Howzit.named_arguments ||= {}
|
|
737
829
|
Howzit.named_arguments.merge!(@named_args) if @named_args
|
|
738
830
|
|
|
739
|
-
unless @prereqs.empty?
|
|
740
|
-
begin
|
|
741
|
-
puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
|
|
742
|
-
rescue Errno::EPIPE
|
|
743
|
-
# Pipe closed, ignore
|
|
744
|
-
end
|
|
745
|
-
res = Prompt.yn('Have the above prerequisites been met?', default: true)
|
|
746
|
-
Process.exit 1 unless res
|
|
747
|
-
end
|
|
748
|
-
|
|
749
831
|
# Process directives sequentially
|
|
750
832
|
while directive_index < @directives.length
|
|
751
833
|
directive = @directives[directive_index]
|
|
@@ -904,30 +986,6 @@ module Howzit
|
|
|
904
986
|
re_evaluate_conditionals(conditional_state, directive_index - 1, context)
|
|
905
987
|
end
|
|
906
988
|
|
|
907
|
-
if @results[:total].positive?
|
|
908
|
-
total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
|
|
909
|
-
errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
|
|
910
|
-
@results[:message] += if @results[:errors].zero?
|
|
911
|
-
"{bg}\u{2713} {by}Ran #{total}{x}".c
|
|
912
|
-
elsif Howzit.options[:force]
|
|
913
|
-
"{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
|
|
914
|
-
else
|
|
915
|
-
"{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
|
|
916
|
-
end
|
|
917
|
-
output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
|
|
918
|
-
end
|
|
919
|
-
|
|
920
|
-
unless @postreqs.empty?
|
|
921
|
-
begin
|
|
922
|
-
# Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
|
|
923
|
-
postreqs_content = @postreqs.join("\n\n").render_arguments
|
|
924
|
-
wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
|
|
925
|
-
puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
|
|
926
|
-
rescue Errno::EPIPE
|
|
927
|
-
# Pipe closed, ignore
|
|
928
|
-
end
|
|
929
|
-
end
|
|
930
|
-
|
|
931
989
|
output
|
|
932
990
|
end
|
|
933
991
|
|
|
@@ -944,7 +1002,7 @@ module Howzit
|
|
|
944
1002
|
last_idx = path_to_check.last
|
|
945
1003
|
last_state = conditional_state[last_idx]
|
|
946
1004
|
if last_state && %w[elsif else].include?(last_state[:directive_type])
|
|
947
|
-
parent_if_idx = path_to_check[
|
|
1005
|
+
parent_if_idx = path_to_check[-2]
|
|
948
1006
|
parent_if_state = conditional_state[parent_if_idx]
|
|
949
1007
|
if parent_if_state && %w[if unless].include?(parent_if_state[:directive_type])
|
|
950
1008
|
path_to_check.delete(parent_if_idx)
|
data/lib/howzit/util.rb
CHANGED
|
@@ -123,42 +123,29 @@ module Howzit
|
|
|
123
123
|
rescue Errno::EPIPE
|
|
124
124
|
# Pipe closed, ignore
|
|
125
125
|
end
|
|
126
|
-
return
|
|
126
|
+
return true
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
input = $stdin
|
|
132
|
-
|
|
133
|
-
pid = Kernel.fork do
|
|
134
|
-
write_io.close
|
|
135
|
-
input.reopen(read_io)
|
|
136
|
-
read_io.close
|
|
137
|
-
|
|
138
|
-
# Wait until we have input before we start the pager
|
|
139
|
-
IO.select [input]
|
|
140
|
-
|
|
141
|
-
pager = which_pager
|
|
142
|
-
|
|
129
|
+
pager = which_pager
|
|
130
|
+
if pager.nil?
|
|
143
131
|
begin
|
|
144
|
-
|
|
145
|
-
rescue
|
|
146
|
-
|
|
147
|
-
exit 1
|
|
132
|
+
puts text
|
|
133
|
+
rescue Errno::EPIPE
|
|
134
|
+
# Pipe closed, ignore
|
|
148
135
|
end
|
|
136
|
+
return true
|
|
149
137
|
end
|
|
150
138
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
139
|
+
# Write the full buffer to the pager stdin before closing it. The old
|
|
140
|
+
# fork + IO.select approach started less as soon as the first bytes
|
|
141
|
+
# arrived, which could leave the initial viewport scrolled past line 1.
|
|
142
|
+
IO.popen(pager, 'w') do |io|
|
|
143
|
+
io.write(text)
|
|
154
144
|
rescue Errno::EPIPE
|
|
155
145
|
# User quit pager before we finished writing, ignore
|
|
156
146
|
end
|
|
157
|
-
write_io.close
|
|
158
|
-
|
|
159
|
-
_, status = Process.waitpid2(pid)
|
|
160
147
|
|
|
161
|
-
|
|
148
|
+
Process.last_status.success?
|
|
162
149
|
end
|
|
163
150
|
|
|
164
151
|
# Strip safely when external encoding rejects multibyte content (e.g. US-ASCII default).
|
data/lib/howzit/version.rb
CHANGED
data/spec/cli_spec.rb
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
describe 'includes: metadata' do
|
|
8
|
+
let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_includes_test')) }
|
|
9
|
+
let(:external_dir) { File.expand_path(File.join(temp_dir, 'external_project')) }
|
|
10
|
+
let(:main_dir) { File.expand_path(File.join(temp_dir, 'main_project')) }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
FileUtils.mkdir_p(external_dir)
|
|
14
|
+
FileUtils.mkdir_p(main_dir)
|
|
15
|
+
|
|
16
|
+
File.write(
|
|
17
|
+
File.join(external_dir, 'buildnotes.md'),
|
|
18
|
+
<<~NOTE
|
|
19
|
+
# External Project
|
|
20
|
+
|
|
21
|
+
## Shared Topic
|
|
22
|
+
|
|
23
|
+
@run(echo external) External task
|
|
24
|
+
|
|
25
|
+
## External Only
|
|
26
|
+
|
|
27
|
+
@run(echo only_external) Only in external
|
|
28
|
+
NOTE
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
File.write(
|
|
32
|
+
File.join(main_dir, 'buildnotes.md'),
|
|
33
|
+
<<~NOTE
|
|
34
|
+
includes: #{external_dir}
|
|
35
|
+
|
|
36
|
+
# Main Project
|
|
37
|
+
|
|
38
|
+
## Shared Topic
|
|
39
|
+
|
|
40
|
+
@run(echo local) Local task wins
|
|
41
|
+
|
|
42
|
+
## Main Only
|
|
43
|
+
|
|
44
|
+
@run(echo main) Main task
|
|
45
|
+
NOTE
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
Dir.chdir(main_dir)
|
|
49
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
50
|
+
Howzit.options[:stack] = false
|
|
51
|
+
Howzit.options[:include_upstream] = false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
after do
|
|
55
|
+
Dir.chdir(Dir.tmpdir)
|
|
56
|
+
FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
|
|
57
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'loads topics from an included project directory' do
|
|
61
|
+
buildnote = Howzit::BuildNote.new
|
|
62
|
+
external = buildnote.find_topic('External Only')
|
|
63
|
+
|
|
64
|
+
expect(external).not_to be_empty
|
|
65
|
+
expect(external[0].title).to eq('external_project:External Only')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'prefers local topics over included topics with the same name' do
|
|
69
|
+
buildnote = Howzit::BuildNote.new
|
|
70
|
+
matches = buildnote.find_topic('Shared Topic')
|
|
71
|
+
|
|
72
|
+
expect(matches.length).to eq(1)
|
|
73
|
+
expect(matches[0].title).to eq('Shared Topic')
|
|
74
|
+
expect(matches[0].tasks.first.action).to include('local')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'includes topics from an explicit file path' do
|
|
78
|
+
shared_file = File.join(temp_dir, 'shared.md')
|
|
79
|
+
File.write(
|
|
80
|
+
shared_file,
|
|
81
|
+
<<~NOTE
|
|
82
|
+
# Shared file
|
|
83
|
+
|
|
84
|
+
## From File
|
|
85
|
+
|
|
86
|
+
@run(echo from_file) From file
|
|
87
|
+
NOTE
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
File.write(
|
|
91
|
+
File.join(main_dir, 'buildnotes.md'),
|
|
92
|
+
<<~NOTE
|
|
93
|
+
includes: #{shared_file}
|
|
94
|
+
|
|
95
|
+
# Main
|
|
96
|
+
|
|
97
|
+
## Local
|
|
98
|
+
NOTE
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
102
|
+
buildnote = Howzit::BuildNote.new
|
|
103
|
+
topic = buildnote.find_topic('From File')[0]
|
|
104
|
+
|
|
105
|
+
expect(topic.title).to eq('shared:From File')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'supports subtopic filters like templates' do
|
|
109
|
+
File.write(
|
|
110
|
+
File.join(main_dir, 'buildnotes.md'),
|
|
111
|
+
<<~NOTE
|
|
112
|
+
includes: #{external_dir}[External Only]
|
|
113
|
+
|
|
114
|
+
# Main
|
|
115
|
+
NOTE
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
119
|
+
buildnote = Howzit::BuildNote.new
|
|
120
|
+
|
|
121
|
+
expect(buildnote.find_topic('External Only')).not_to be_empty
|
|
122
|
+
expect(buildnote.find_topic('Shared Topic')).to be_empty
|
|
123
|
+
end
|
|
124
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
describe 'subtopics' do
|
|
8
|
+
let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_subtopics_test')) }
|
|
9
|
+
let(:buildnote) { Howzit::BuildNote.new }
|
|
10
|
+
let(:parent) { buildnote.find_topic('Parent')[0] }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
File.write(
|
|
14
|
+
File.join(temp_dir, 'buildnotes.md'),
|
|
15
|
+
<<~NOTE
|
|
16
|
+
# Subtopic Project
|
|
17
|
+
|
|
18
|
+
## Parent
|
|
19
|
+
|
|
20
|
+
Parent content
|
|
21
|
+
|
|
22
|
+
@run(echo parent) Parent task
|
|
23
|
+
|
|
24
|
+
### Child One
|
|
25
|
+
|
|
26
|
+
@run(echo child1) Child one task
|
|
27
|
+
|
|
28
|
+
#### Grandchild
|
|
29
|
+
|
|
30
|
+
@run(echo grand) Grandchild task
|
|
31
|
+
|
|
32
|
+
### Child Two
|
|
33
|
+
|
|
34
|
+
@run(echo child2) Child two task
|
|
35
|
+
|
|
36
|
+
## Sibling
|
|
37
|
+
|
|
38
|
+
@run(echo sibling) Sibling task
|
|
39
|
+
NOTE
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
Dir.chdir(temp_dir)
|
|
43
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
44
|
+
Howzit.options[:stack] = false
|
|
45
|
+
Howzit.options[:include_upstream] = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
after do
|
|
49
|
+
Dir.chdir(Dir.tmpdir)
|
|
50
|
+
FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
|
|
51
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def topic_named(title)
|
|
55
|
+
buildnote.topics.find { |t| t.title == title }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'nests deeper headers under the preceding shallower header' do
|
|
59
|
+
expect(parent.subtopics.map(&:title)).to eq(['Child One', 'Child Two'])
|
|
60
|
+
expect(topic_named('Child One').subtopics.map(&:title)).to eq(['Grandchild'])
|
|
61
|
+
expect(topic_named('Grandchild').ancestors.map(&:title)).to eq(['Child One', 'Parent'])
|
|
62
|
+
expect(topic_named('Sibling').parent_topic).to be_nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'keeps subtopics selectable on their own' do
|
|
66
|
+
expect(buildnote.find_topic('Child Two')[0].title).to eq('Child Two')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'collects tasks from subtopics in order' do
|
|
70
|
+
expect(parent.all_tasks.map(&:title)).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'displays subtopics with the parent topic' do
|
|
74
|
+
output = parent.print_out.join("\n").uncolor
|
|
75
|
+
|
|
76
|
+
expect(output).to include('Parent content')
|
|
77
|
+
expect(output).to include('Child One')
|
|
78
|
+
expect(output).to include('Grandchild')
|
|
79
|
+
expect(output).to include('echo child2')
|
|
80
|
+
expect(output).not_to include('Sibling')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'only outputs top-level topics when showing all topics' do
|
|
84
|
+
expect(buildnote.top_level_topics.map(&:title)).to eq(%w[Parent Sibling])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'indents subtopics in the topic list' do
|
|
88
|
+
list = buildnote.list.uncolor
|
|
89
|
+
|
|
90
|
+
expect(list).to include("\n- Parent")
|
|
91
|
+
expect(list).to include("\n - Child One")
|
|
92
|
+
expect(list).to include("\n - Grandchild")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe 'running a parent topic' do
|
|
96
|
+
let(:ran) { [] }
|
|
97
|
+
|
|
98
|
+
it 'runs its own tasks followed by all subtopic tasks' do
|
|
99
|
+
ran_titles = ran
|
|
100
|
+
allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
|
|
101
|
+
ran_titles << task.title
|
|
102
|
+
[[], 1, true]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
parent.run
|
|
106
|
+
|
|
107
|
+
expect(ran).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
|
|
108
|
+
expect(parent.results[:total]).to eq(4)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'stops running subtopics after an error' do
|
|
112
|
+
ran_titles = ran
|
|
113
|
+
allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
|
|
114
|
+
ran_titles << task.title
|
|
115
|
+
[[], 1, task.title != 'Child one task']
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
parent.run
|
|
119
|
+
|
|
120
|
+
expect(ran).to eq(['Parent task', 'Child one task'])
|
|
121
|
+
expect(parent.results[:errors]).to eq(1)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'continues past errors with --force' do
|
|
125
|
+
Howzit.options[:force] = true
|
|
126
|
+
ran_titles = ran
|
|
127
|
+
allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
|
|
128
|
+
ran_titles << task.title
|
|
129
|
+
[[], 1, task.title != 'Child one task']
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
parent.run
|
|
133
|
+
|
|
134
|
+
expect(ran).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
|
|
135
|
+
ensure
|
|
136
|
+
Howzit.options[:force] = false
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: howzit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.1.
|
|
4
|
+
version: 2.1.44
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brett Terpstra
|
|
@@ -13,14 +13,14 @@ dependencies:
|
|
|
13
13
|
name: bundler
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '2.2'
|
|
19
19
|
type: :development
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
|
-
- - "
|
|
23
|
+
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '2.2'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
@@ -320,6 +320,7 @@ files:
|
|
|
320
320
|
- spec/condition_evaluator_spec.rb
|
|
321
321
|
- spec/conditional_blocks_integration_spec.rb
|
|
322
322
|
- spec/conditional_content_spec.rb
|
|
323
|
+
- spec/includes_metadata_spec.rb
|
|
323
324
|
- spec/log_level_spec.rb
|
|
324
325
|
- spec/prompt_spec.rb
|
|
325
326
|
- spec/ruby_gem_spec.rb
|
|
@@ -330,6 +331,7 @@ files:
|
|
|
330
331
|
- spec/spec_helper.rb
|
|
331
332
|
- spec/stack_mode_spec.rb
|
|
332
333
|
- spec/stringutils_spec.rb
|
|
334
|
+
- spec/subtopics_spec.rb
|
|
333
335
|
- spec/task_spec.rb
|
|
334
336
|
- spec/topic_positional_snapshot_spec.rb
|
|
335
337
|
- spec/topic_spec.rb
|
|
@@ -355,7 +357,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
355
357
|
- !ruby/object:Gem::Version
|
|
356
358
|
version: '0'
|
|
357
359
|
requirements: []
|
|
358
|
-
rubygems_version:
|
|
360
|
+
rubygems_version: 4.0.18
|
|
359
361
|
specification_version: 4
|
|
360
362
|
summary: Provides a way to access Markdown project notes by topic with query capabilities
|
|
361
363
|
and the ability to execute the tasks it describes.
|
|
@@ -365,6 +367,7 @@ test_files:
|
|
|
365
367
|
- spec/condition_evaluator_spec.rb
|
|
366
368
|
- spec/conditional_blocks_integration_spec.rb
|
|
367
369
|
- spec/conditional_content_spec.rb
|
|
370
|
+
- spec/includes_metadata_spec.rb
|
|
368
371
|
- spec/log_level_spec.rb
|
|
369
372
|
- spec/prompt_spec.rb
|
|
370
373
|
- spec/ruby_gem_spec.rb
|
|
@@ -375,6 +378,7 @@ test_files:
|
|
|
375
378
|
- spec/spec_helper.rb
|
|
376
379
|
- spec/stack_mode_spec.rb
|
|
377
380
|
- spec/stringutils_spec.rb
|
|
381
|
+
- spec/subtopics_spec.rb
|
|
378
382
|
- spec/task_spec.rb
|
|
379
383
|
- spec/topic_positional_snapshot_spec.rb
|
|
380
384
|
- spec/topic_spec.rb
|