howzit 2.1.41 → 2.1.42

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30837683e82b813fd1fd8ff3e2df3177cbee3c735fc5afb2cb3394f08fb341eb
4
- data.tar.gz: 4a0113424a3e36169a0f7a66f31b6dcc3adc24785d9a1a54829db7d9d907c585
3
+ metadata.gz: 8916b2ac37cd920db8008fe7a9aa28a80b4c5265083b4b257fb8ea1113938efb
4
+ data.tar.gz: 9486d6ca52f82ea5059bffd0de58333b82eedfc623d03f6325817caf8c956d4f
5
5
  SHA512:
6
- metadata.gz: 1c0245bdb6e801d1efb96e800c04a7ac1177a589e9d25753322bb70a6690ce22b738fdfdc12956aae216565e93ea23b7cdcc14b6f278937e329b116b8d40b5cd
7
- data.tar.gz: 986c7c270fc4d8aa21c395fab7a1edaa5669c4a72e6160dede78e45c9c9b6065e69ca47c6c4253b1eae1ec2909d4e31224dee333ba74e6b392e937fe1f7f25ae
6
+ metadata.gz: 5819786618c45538e42f21243102043b1ed408f516b0b946adaa9a2819d0080c2804cba47987f4ba0ed98b21ad7499d42cc825c2f89efb63cc4f6d8013bd743d
7
+ data.tar.gz: 2a90edf5e41602e1c6fc50ab21a6bfc3531b598f3a5cc381492f284bbb588b2590589fbc23da0f007a021605d46f7246e2908eed89895c175c24838b4755b285
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ### 2.1.42
2
+
3
+ 2026-06-29 06:52
4
+
5
+ #### NEW
6
+
7
+ - `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.
8
+
9
+ #### FIXED
10
+
11
+ - 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).
12
+
1
13
  ### 2.1.41
2
14
 
3
15
  2026-05-10 08:18
@@ -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
@@ -653,7 +657,7 @@ module Howzit
653
657
  ## @return [Array] extracted topics
654
658
  ##
655
659
  def read_template(template, file, subtopics = nil)
656
- note = BuildNote.new(file: file, meta: @metadata)
660
+ note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
657
661
 
658
662
  template_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
659
663
  template_topics.map do |topic|
@@ -751,6 +755,24 @@ module Howzit
751
755
  files.min
752
756
  end
753
757
 
758
+ ##
759
+ ## Glob a directory for a valid build note filename
760
+ ##
761
+ ## @param dir [String] Directory to search
762
+ ##
763
+ ## @return [String, nil] Absolute path to build note file
764
+ ##
765
+ def glob_note_in(dir)
766
+ pattern = File.join(File.expand_path(dir), '*.{txt,md,markdown}')
767
+ files = Dir.glob(pattern).select { |f| File.basename(f).build_note? }
768
+ return nil if files.empty?
769
+
770
+ priority_files = files.select { |f| File.basename(f) =~ /^(buildnotes|howzit)\./i }
771
+ return priority_files.min unless priority_files.empty?
772
+
773
+ files.min
774
+ end
775
+
754
776
  ##
755
777
  ## Search for a valid build note, checking current
756
778
  ## directory, git top level directory, and parent
@@ -794,26 +816,117 @@ module Howzit
794
816
  end
795
817
 
796
818
  ##
797
- ## Read a list of topics from an included template
819
+ ## Read a list of topics from included templates and includes: metadata
798
820
  ##
799
- ## @param content [String] The template contents
821
+ ## @param content [String] The build note contents
800
822
  ##
801
823
  def get_template_topics(content)
802
824
  leader = content.split(/^#/)[0].strip
803
825
 
804
- template_topics = []
826
+ external_topics = []
805
827
 
806
- return template_topics if leader.empty?
828
+ return external_topics if leader.empty?
807
829
 
808
830
  data = leader.metadata
809
831
 
810
832
  if data.key?('template')
811
833
  templates = data['template'].strip.split(/\s*,\s*/)
812
834
 
813
- template_topics.concat(gather_templates(templates))
835
+ external_topics.concat(gather_templates(templates))
814
836
  end
815
837
 
816
- template_topics
838
+ if data.key?('includes') && !@skip_includes
839
+ includes = data['includes'].strip.split(/\s*,\s*/)
840
+
841
+ external_topics.concat(gather_includes(includes))
842
+ end
843
+
844
+ external_topics
845
+ end
846
+
847
+ ##
848
+ ## Resolve an includes: metadata entry to a label and build note file path
849
+ ##
850
+ ## @param path_spec [String] File path, directory, or ~ path
851
+ ##
852
+ ## @return [Array, nil] [label, absolute_file_path] or nil if not found
853
+ ##
854
+ def resolve_include_path(path_spec)
855
+ path = File.expand_path(path_spec.to_s.strip)
856
+ return nil unless File.exist?(path)
857
+
858
+ if File.directory?(path)
859
+ note_path = glob_note_in(path)
860
+ return nil unless note_path
861
+
862
+ [File.basename(path), File.expand_path(note_path)]
863
+ elsif File.file?(path)
864
+ [File.basename(path, File.extname(path)), path]
865
+ end
866
+ end
867
+
868
+ ##
869
+ ## Read topics from an external build note referenced by includes: metadata
870
+ ##
871
+ def read_include(label, file, subtopics = nil)
872
+ note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
873
+ included_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
874
+ included_topics.map do |topic|
875
+ topic.parent = label
876
+ topic.content = topic.content.render_template(@metadata)
877
+ topic.instance_variable_set(:@title, "#{label}:#{topic.title}") unless topic.title.include?(':')
878
+ topic
879
+ end
880
+ end
881
+
882
+ ##
883
+ ## Load topics from includes: metadata paths (comma-separated)
884
+ ##
885
+ def gather_includes(includes)
886
+ included_topics = []
887
+
888
+ includes.each do |spec|
889
+ spec = spec.strip
890
+ next if spec.empty?
891
+
892
+ path_spec, subtopics = detect_subtopics(spec.dup)
893
+ resolved = resolve_include_path(path_spec)
894
+ unless resolved
895
+ Howzit.console.warn "{br}WARNING:{xr} includes: path not found or has no build note: {bw}#{path_spec}{x}".c
896
+ next
897
+ end
898
+
899
+ label, file = resolved
900
+ abs_file = File.expand_path(file)
901
+ main_file = @note_file ? File.expand_path(@note_file) : nil
902
+
903
+ if main_file && abs_file == main_file
904
+ Howzit.console.warn "{br}WARNING:{xr} includes: skipping self-reference: {bw}#{path_spec}{x}".c
905
+ next
906
+ end
907
+
908
+ if @included_paths.include?(abs_file)
909
+ Howzit.console.warn "{br}WARNING:{xr} includes: already loaded, skipping: {bw}#{path_spec}{x}".c
910
+ next
911
+ end
912
+
913
+ @included_paths.add(abs_file)
914
+ included_topics.concat(read_include(label, abs_file, subtopics))
915
+ end
916
+
917
+ included_topics
918
+ end
919
+
920
+ ##
921
+ ## Merge external topics (templates, includes) into local topics; local wins on duplicate names
922
+ ##
923
+ def merge_external_topics(local_topics, external_topics)
924
+ external_topics.each do |topic|
925
+ base = base_topic_name(topic.title)
926
+ exists_in_local = local_topics.any? { |t| base_topic_name(t.title) == base }
927
+ local_topics.push(topic) unless exists_in_local
928
+ end
929
+ local_topics
817
930
  end
818
931
 
819
932
  # Read in the build notes file and output a hash of
@@ -870,13 +983,7 @@ module Howzit
870
983
  topics.push(topic)
871
984
  end
872
985
 
873
- template_topics.each do |topic|
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
986
+ merge_external_topics(topics, template_topics)
880
987
 
881
988
  topics
882
989
  end
data/lib/howzit/topic.rb CHANGED
@@ -33,8 +33,9 @@ module Howzit
33
33
  end
34
34
 
35
35
  # Get named arguments from title
36
- # from_cli_snapshot: use Howzit.cli_topic_positional_args (set once from argv after ` -- `) so earlier topics' gather_tasks
37
- # cannot clobber positional binding. Re-entrant calls (e.g. @include with [a,b]) pass false to use live Howzit.arguments.
36
+ # from_cli_snapshot: use Howzit.cli_topic_positional_args (argv after `--`) so earlier
37
+ # topics' gather_tasks cannot clobber positional binding. Re-entrant @include [a,b]
38
+ # calls pass false to use live Howzit.arguments.
38
39
  def arguments(from_cli_snapshot: false)
39
40
  @arg_definitions = []
40
41
  return unless @title =~ /\(.*?\) *$/
@@ -944,7 +945,7 @@ module Howzit
944
945
  last_idx = path_to_check.last
945
946
  last_state = conditional_state[last_idx]
946
947
  if last_state && %w[elsif else].include?(last_state[:directive_type])
947
- parent_if_idx = path_to_check[path_to_check.length - 2]
948
+ parent_if_idx = path_to_check[-2]
948
949
  parent_if_state = conditional_state[parent_if_idx]
949
950
  if parent_if_state && %w[if unless].include?(parent_if_state[:directive_type])
950
951
  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
- read_io, write_io = IO.pipe
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
- exec(pager)
145
- rescue SystemCallError => e
146
- Howzit.console.error(e)
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
- read_io.close
152
- begin
153
- write_io.write(text)
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
- status.success?
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).
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.41'
6
+ VERSION = '2.1.42'
7
7
  end
data/spec/cli_spec.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'cli-test'
4
5
 
5
6
  # https://github.com/thoiberg/cli-test
6
7
  describe 'CLI' do
@@ -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
@@ -8,7 +8,6 @@ unless ENV['CI'] == 'true'
8
8
  end
9
9
 
10
10
  require 'howzit'
11
- require 'cli-test'
12
11
 
13
12
  RSpec.configure do |c|
14
13
  c.expect_with(:rspec) { |e| e.syntax = :expect }
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.41
4
+ version: 2.1.42
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -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
@@ -365,6 +366,7 @@ test_files:
365
366
  - spec/condition_evaluator_spec.rb
366
367
  - spec/conditional_blocks_integration_spec.rb
367
368
  - spec/conditional_content_spec.rb
369
+ - spec/includes_metadata_spec.rb
368
370
  - spec/log_level_spec.rb
369
371
  - spec/prompt_spec.rb
370
372
  - spec/ruby_gem_spec.rb