hiiro 0.1.82 → 0.1.83

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: 31fd73a56f9bbcc10a8a9e62ecdd0dcbc391eb65e0bcf5886ab289986f81a198
4
- data.tar.gz: dd392dc066b2ec7c894a2d7c2d14c8d2921f157a4b998dde9ee7ffdb83400844
3
+ metadata.gz: 599e565871c2afc37ae659954e4ccf01568bed4375032fb9d8ba21007e7e2673
4
+ data.tar.gz: 4fdcbf43a79f2b63ec3f95ecd003973cb3175b4ef0b6cdbb0cbc36444439b259
5
5
  SHA512:
6
- metadata.gz: 705c6f8c3aa58a2f356272a647d67fb84a7b7c6c39b271c2372e4b3498a0d644415a75d2fad9ff16c80a73949fdc432052e6ef7d00e97149c412eb290c1fb13a
7
- data.tar.gz: df6abb83f94c9433a992f359d66393c8c3c9db28c5d920d32529bf58a3fe536114da0396298fdf4400b062058acb054e57dc83696d5d5ada96a9319ef8ee5e86
6
+ metadata.gz: e8945bc066927371fa804f6b0deef00ee36f84be0fc9178033e52d5228ce5f6b895b114cb3a0a37129e491f2c8d4a1156cfce36e18740d28048fa7885ad1f792
7
+ data.tar.gz: 31acaf4e204e3be5f8c6fddbf6009f0e757bad86e3dcbe5c770637c05d848a6c4a65c42552617e015bd8b55171aecc9d2fde86b58e6b16ed06ef23f8bdef6179
data/bin/h-branch CHANGED
@@ -19,10 +19,6 @@ class BranchManager
19
19
  puts
20
20
  puts "Subcommands:"
21
21
  puts " save Record current branch for this task"
22
- puts " history List branch history (oldest to newest)"
23
- puts " history --worktree=X Filter by worktree"
24
- puts " history --task=X Filter by task"
25
- puts " history --session=X Filter by tmux session"
26
22
  puts " current Show current branch info"
27
23
  end
28
24
 
@@ -62,35 +58,6 @@ class BranchManager
62
58
  true
63
59
  end
64
60
 
65
- def history(args = [])
66
- filters = parse_filters(args)
67
- data = load_data
68
- branches = data['branches'] || []
69
-
70
- if branches.empty?
71
- puts "No branches recorded."
72
- puts "Use 'h branch save' to record the current branch."
73
- return
74
- end
75
-
76
- # Apply filters
77
- branches = filter_entries(branches, filters)
78
-
79
- if branches.empty?
80
- puts "No branches match the given filters."
81
- return
82
- end
83
-
84
- # Sort by created_at (oldest first)
85
- branches = branches.sort_by { |b| b['created_at'] || '' }
86
-
87
- puts "Branch history (oldest to newest):"
88
- puts
89
- branches.each_with_index do |branch, idx|
90
- puts format_entry(branch, idx + 1)
91
- end
92
- end
93
-
94
61
  def current
95
62
  branch_name = current_branch
96
63
  unless branch_name
@@ -132,48 +99,6 @@ class BranchManager
132
99
  }
133
100
  end
134
101
 
135
- def parse_filters(args)
136
- filters = {}
137
- args.each do |arg|
138
- case arg
139
- when /^--worktree=(.+)$/
140
- filters[:worktree] = $1
141
- when /^--task=(.+)$/
142
- filters[:task] = $1
143
- when /^--session=(.+)$/
144
- filters[:session] = $1
145
- when /^-w(.+)$/
146
- filters[:worktree] = $1
147
- when /^-t(.+)$/
148
- filters[:task] = $1
149
- when /^-s(.+)$/
150
- filters[:session] = $1
151
- end
152
- end
153
- filters
154
- end
155
-
156
- def filter_entries(entries, filters)
157
- entries.select do |entry|
158
- next false if filters[:worktree] && !entry['worktree']&.include?(filters[:worktree])
159
- next false if filters[:task] && !entry['task']&.include?(filters[:task])
160
- next false if filters[:session] && entry.dig('tmux', 'session') != filters[:session]
161
- true
162
- end
163
- end
164
-
165
- def format_entry(entry, num)
166
- lines = []
167
- lines << "#{num}. #{entry['name']}"
168
- lines << " Task: #{entry['task'] || '(none)'}"
169
- lines << " Worktree: #{entry['worktree'] || '(none)'}"
170
- if entry['tmux']
171
- lines << " Tmux: #{entry['tmux']['session']}/#{entry['tmux']['window']}"
172
- end
173
- lines << " Created: #{entry['created_at']}"
174
- lines.join("\n")
175
- end
176
-
177
102
  def show_entry(entry)
178
103
  puts " Branch: #{entry[:name]}"
179
104
  puts " Task: #{entry[:task] || '(none)'}"
@@ -204,7 +129,6 @@ manager = BranchManager.new(hiiro)
204
129
 
205
130
  hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
206
131
  hiiro.add_subcmd(:save) { manager.save }
207
- hiiro.add_subcmd(:history) { |*args| manager.history(args) }
208
132
  hiiro.add_subcmd(:current) { manager.current }
209
133
  hiiro.add_subcmd(:select) do |*args|
210
134
  branches = hiiro.git.branches(sort_by: 'authordate', ignore_case: true)
@@ -375,6 +299,115 @@ hiiro.add_subcmd(:behind) { |*args|
375
299
  puts "#{branch} is #{count} commit(s) behind #{behind_of}"
376
300
  }
377
301
 
302
+ hiiro.add_subcmd(:log) { |upstream = nil|
303
+ upstream ||= %w[origin/master master origin/main main].find { |ref|
304
+ system("git rev-parse --verify #{ref} >/dev/null 2>&1")
305
+ }
306
+
307
+ unless upstream
308
+ puts "Cannot find master or origin/master"
309
+ next
310
+ end
311
+
312
+ forkpoint = `git merge-base --fork-point #{upstream} HEAD 2>/dev/null`.strip
313
+ if forkpoint.empty?
314
+ forkpoint = `git merge-base #{upstream} HEAD 2>/dev/null`.strip
315
+ end
316
+
317
+ if forkpoint.empty?
318
+ puts "Could not find fork point"
319
+ next
320
+ end
321
+
322
+ system('git', 'log', '--oneline', '--decorate', "#{forkpoint}..HEAD")
323
+ }
324
+
325
+ hiiro.add_subcmd(:forkpoint) { |upstream = nil, branch = nil|
326
+ branch ||= 'HEAD'
327
+ upstream ||= %w[origin/master master origin/main main].find { |ref|
328
+ system("git rev-parse --verify #{ref} >/dev/null 2>&1")
329
+ }
330
+
331
+ unless upstream
332
+ puts "Cannot find master or origin/master"
333
+ next
334
+ end
335
+
336
+ forkpoint = `git merge-base --fork-point #{upstream} #{branch} 2>/dev/null`.strip
337
+
338
+ if forkpoint.empty?
339
+ # Fallback to regular merge-base if fork-point fails
340
+ forkpoint = `git merge-base #{upstream} #{branch} 2>/dev/null`.strip
341
+ end
342
+
343
+ if forkpoint.empty?
344
+ puts "Could not find fork point between #{upstream} and #{branch}"
345
+ else
346
+ puts forkpoint
347
+ end
348
+ }
349
+
350
+ hiiro.add_subcmd(:changed) { |*args|
351
+ show_all = args.delete('-a') || args.delete('--all')
352
+ upstream = args.first
353
+
354
+ upstream ||= %w[origin/master master origin/main main].find { |ref|
355
+ system("git rev-parse --verify #{ref} >/dev/null 2>&1")
356
+ }
357
+
358
+ unless upstream
359
+ puts "Cannot find master or origin/master"
360
+ next
361
+ end
362
+
363
+ forkpoint = `git merge-base --fork-point #{upstream} HEAD 2>/dev/null`.strip
364
+ if forkpoint.empty?
365
+ forkpoint = `git merge-base #{upstream} HEAD 2>/dev/null`.strip
366
+ end
367
+
368
+ if forkpoint.empty?
369
+ puts "Could not find fork point"
370
+ next
371
+ end
372
+
373
+ if show_all
374
+ system('git', 'diff', '--name-only', "#{forkpoint}...HEAD")
375
+ else
376
+ system('git', 'diff', '--name-only', '--relative', "#{forkpoint}...HEAD")
377
+ end
378
+ }
379
+
380
+
381
+ hiiro.add_subcmd(:changed) { |*args|
382
+ show_all = args.delete('-a') || args.delete('--all')
383
+ upstream = args.first
384
+
385
+ upstream ||= %w[origin/master master origin/main main].find { |ref|
386
+ system("git rev-parse --verify #{ref} >/dev/null 2>&1")
387
+ }
388
+
389
+ unless upstream
390
+ puts "Cannot find master or origin/master"
391
+ next
392
+ end
393
+
394
+ forkpoint = `git merge-base --fork-point #{upstream} HEAD 2>/dev/null`.strip
395
+ if forkpoint.empty?
396
+ forkpoint = `git merge-base #{upstream} HEAD 2>/dev/null`.strip
397
+ end
398
+
399
+ if forkpoint.empty?
400
+ puts "Could not find fork point"
401
+ next
402
+ end
403
+
404
+ if show_all
405
+ system('git', 'diff', '--name-only', "#{forkpoint}...HEAD")
406
+ else
407
+ system('git', 'diff', '--name-only', '--relative', "#{forkpoint}...HEAD")
408
+ end
409
+ }
410
+
378
411
  hiiro.add_subcmd(:ancestor) { |*args|
379
412
  case args.length
380
413
  when 0
data/bin/h-pane CHANGED
@@ -60,17 +60,6 @@ o.add_subcmd(:resize) { |*args|
60
60
  system('tmux', 'resize-pane', *args)
61
61
  }
62
62
 
63
- o.add_subcmd(:history) { |pane_id=nil|
64
- entries = Hiiro::History.by_pane(pane_id)
65
- if entries.empty?
66
- puts "No history for this pane"
67
- else
68
- puts "History for pane: #{pane_id || ENV['TMUX_PANE'] || 'current'}"
69
- puts
70
- entries.last(20).each_with_index { |e, i| puts e.oneline(i + 1) }
71
- end
72
- }
73
-
74
63
  o.add_subcmd(:select) do |*args|
75
64
  # Get all panes with details
76
65
  output = `tmux list-panes -a -F '\#{session_name}:\#{window_index}.\#{pane_index} [\#{pane_current_command}] \#{pane_current_path}'`.strip
data/bin/h-pr CHANGED
@@ -21,10 +21,6 @@ class PRManager
21
21
  puts
22
22
  puts "Subcommands:"
23
23
  puts " save [PR_NUMBER] Record PR for this task (auto-detects if omitted)"
24
- puts " history List PR history (oldest to newest)"
25
- puts " history --worktree=X Filter by worktree"
26
- puts " history --task=X Filter by task"
27
- puts " history --session=X Filter by tmux session"
28
24
  puts " current Show current branch's PR info"
29
25
  puts " open [PR_NUMBER] Open PR in browser"
30
26
  puts " view [PR_NUMBER] View PR details in terminal"
@@ -67,35 +63,6 @@ class PRManager
67
63
  true
68
64
  end
69
65
 
70
- def history(args = [])
71
- filters = parse_filters(args)
72
- data = load_data
73
- prs = data['prs'] || []
74
-
75
- if prs.empty?
76
- puts "No PRs recorded."
77
- puts "Use 'h pr save' to record the current PR."
78
- return
79
- end
80
-
81
- # Apply filters
82
- prs = filter_entries(prs, filters)
83
-
84
- if prs.empty?
85
- puts "No PRs match the given filters."
86
- return
87
- end
88
-
89
- # Sort by created_at (oldest first)
90
- prs = prs.sort_by { |p| p['created_at'] || '' }
91
-
92
- puts "PR history (oldest to newest):"
93
- puts
94
- prs.each_with_index do |pr, idx|
95
- puts format_entry(pr, idx + 1)
96
- end
97
- end
98
-
99
66
  def current
100
67
  pr_info = fetch_pr_info
101
68
  unless pr_info
@@ -169,36 +136,6 @@ class PRManager
169
136
  }
170
137
  end
171
138
 
172
- def parse_filters(args)
173
- filters = {}
174
- args.each do |arg|
175
- case arg
176
- when /^--worktree=(.+)$/
177
- filters[:worktree] = $1
178
- when /^--task=(.+)$/
179
- filters[:task] = $1
180
- when /^--session=(.+)$/
181
- filters[:session] = $1
182
- when /^-w(.+)$/
183
- filters[:worktree] = $1
184
- when /^-t(.+)$/
185
- filters[:task] = $1
186
- when /^-s(.+)$/
187
- filters[:session] = $1
188
- end
189
- end
190
- filters
191
- end
192
-
193
- def filter_entries(entries, filters)
194
- entries.select do |entry|
195
- next false if filters[:worktree] && !entry['worktree']&.include?(filters[:worktree])
196
- next false if filters[:task] && !entry['task']&.include?(filters[:task])
197
- next false if filters[:session] && entry.dig('tmux', 'session') != filters[:session]
198
- true
199
- end
200
- end
201
-
202
139
  def format_entry(entry, num)
203
140
  lines = []
204
141
  lines << "#{num}. PR ##{entry['number']}: #{entry['title']}"
@@ -541,7 +478,6 @@ hiiro.add_subcmd(:link) { |*args|
541
478
 
542
479
  hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
543
480
  hiiro.add_subcmd(:save) { |pr_number=nil| manager.save(pr_number) }
544
- hiiro.add_subcmd(:history) { |*args| manager.history(args) }
545
481
  hiiro.add_subcmd(:current) { manager.current }
546
482
  hiiro.add_subcmd(:open) { |pr_number=nil| manager.open(pr_number) }
547
483
  hiiro.add_subcmd(:view) { |pr_number=nil| manager.view(pr_number) }
data/bin/h-session CHANGED
@@ -60,16 +60,4 @@ o.add_subcmd(:info) { |*args|
60
60
  system('tmux', 'display-message', '-p', '#{session_name}: #{session_windows} windows, #{session_attached} attached', *args)
61
61
  }
62
62
 
63
- o.add_subcmd(:history) { |session_name=nil|
64
- session_name ||= `tmux display-message -p '#S'`.strip if ENV['TMUX']
65
- entries = Hiiro::History.by_session(session_name)
66
- if entries.empty?
67
- puts "No history for this session"
68
- else
69
- puts "History for session: #{session_name || 'current'}"
70
- puts
71
- entries.last(20).each_with_index { |e, i| puts e.oneline(i + 1) }
72
- end
73
- }
74
-
75
63
  o.run
data/bin/h-window CHANGED
@@ -56,18 +56,6 @@ o.add_subcmd(:unlink) { |*args|
56
56
  system('tmux', 'unlink-window', *args)
57
57
  }
58
58
 
59
- o.add_subcmd(:history) { |window_id=nil|
60
- window_id ||= `tmux display-message -p '#I'`.strip if ENV['TMUX']
61
- entries = Hiiro::History.by_window(window_id)
62
- if entries.empty?
63
- puts "No history for this window"
64
- else
65
- puts "History for window: #{window_id || 'current'}"
66
- puts
67
- entries.last(20).each_with_index { |e, i| puts e.oneline(i + 1) }
68
- end
69
- }
70
-
71
59
  o.add_subcmd(:select) do |*args|
72
60
  # Get all windows with details
73
61
  output = `tmux list-windows -a -F '\#{session_name}:\#{window_index} \#{window_name} [\#{window_panes} panes]'`.strip
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.82"
2
+ VERSION = "0.1.83"
3
3
  end
data/lib/hiiro.rb CHANGED
@@ -6,7 +6,6 @@ require "pry"
6
6
  require_relative "hiiro/version"
7
7
  require_relative "hiiro/prefix_matcher"
8
8
  require_relative "hiiro/git"
9
- require_relative "hiiro/history"
10
9
  require_relative "hiiro/options"
11
10
  require_relative "hiiro/notification"
12
11
  require_relative "hiiro/sk"
@@ -39,7 +38,6 @@ class Hiiro
39
38
  bin_name = values[:bin_name] || $0
40
39
 
41
40
  new(bin_name, *args, logging: logging, **values).tap do |hiiro|
42
- History.load(hiiro)
43
41
  hiiro.load_plugins(*plugins)
44
42
 
45
43
  hiiro.add_subcommand(:pry) { |*args|
@@ -104,16 +102,9 @@ class Hiiro
104
102
  @todo_manager ||= TodoManager.new
105
103
  end
106
104
 
107
- def history
108
- @history ||= History.new
109
- end
110
-
111
105
  def run
112
106
  result = runner.run(*args)
113
107
 
114
- # Track command after running (only saves if state changed and in task context)
115
- History.track(cmd: full_command, hiiro: self)
116
-
117
108
  handle_result(result)
118
109
 
119
110
  exit 1
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hiiro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.82
4
+ version: 0.1.83
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-02-14 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: pry
@@ -100,8 +101,6 @@ files:
100
101
  - lib/hiiro/git/remote.rb
101
102
  - lib/hiiro/git/worktree.rb
102
103
  - lib/hiiro/git/worktrees.rb
103
- - lib/hiiro/history.rb
104
- - lib/hiiro/history/entry.rb
105
104
  - lib/hiiro/notification.rb
106
105
  - lib/hiiro/options.rb
107
106
  - lib/hiiro/prefix_matcher.rb
@@ -127,6 +126,7 @@ metadata:
127
126
  homepage_uri: https://github.com/unixsuperhero/hiiro
128
127
  source_code_uri: https://github.com/unixsuperhero/hiiro
129
128
  changelog_uri: https://github.com/unixsuperhero/hiiro/blob/main/CHANGELOG.md
129
+ post_install_message:
130
130
  rdoc_options: []
131
131
  require_paths:
132
132
  - lib
@@ -141,7 +141,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
141
  - !ruby/object:Gem::Version
142
142
  version: '0'
143
143
  requirements: []
144
- rubygems_version: 3.6.9
144
+ rubygems_version: 3.3.7
145
+ signing_key:
145
146
  specification_version: 4
146
147
  summary: A lightweight CLI framework for Ruby
147
148
  test_files: []
@@ -1,136 +0,0 @@
1
- class Hiiro
2
- class History
3
- class Entry
4
- FIELDS = %i[
5
- id timestamp source cmd description pwd
6
- tmux_session tmux_window tmux_pane
7
- git_branch git_sha git_origin_sha git_worktree
8
- task subtask app
9
- ].freeze
10
-
11
- attr_reader(*FIELDS)
12
-
13
- def initialize(data)
14
- data ||= {}
15
- @id = data['id']
16
- @timestamp = data['timestamp']
17
- @source = data['source']
18
- @cmd = data['cmd']
19
- @description = data['description']
20
- @pwd = data['pwd']
21
- @tmux_session = data['tmux_session']
22
- @tmux_window = data['tmux_window']
23
- @tmux_pane = data['tmux_pane']
24
- @git_branch = data['git_branch']
25
- @git_sha = data['git_sha']
26
- @git_origin_sha = data['git_origin_sha']
27
- @git_worktree = data['git_worktree']
28
- @task = data['task']
29
- @subtask = data['subtask']
30
- @app = data['app']
31
- end
32
-
33
- def to_h
34
- {
35
- 'id' => id,
36
- 'timestamp' => timestamp,
37
- 'source' => source,
38
- 'cmd' => cmd,
39
- 'description' => description,
40
- 'pwd' => pwd,
41
- 'tmux_session' => tmux_session,
42
- 'tmux_window' => tmux_window,
43
- 'tmux_pane' => tmux_pane,
44
- 'git_branch' => git_branch,
45
- 'git_sha' => git_sha,
46
- 'git_origin_sha' => git_origin_sha,
47
- 'git_worktree' => git_worktree,
48
- 'task' => task,
49
- 'subtask' => subtask,
50
- 'app' => app,
51
- }.compact
52
- end
53
-
54
- # Data used for change detection (excludes timestamp, id, cmd)
55
- def state_key
56
- {
57
- 'pwd' => pwd,
58
- 'tmux_session' => tmux_session,
59
- 'tmux_window' => tmux_window,
60
- 'tmux_pane' => tmux_pane,
61
- 'git_branch' => git_branch,
62
- 'git_sha' => git_sha,
63
- 'git_origin_sha' => git_origin_sha,
64
- 'git_worktree' => git_worktree,
65
- 'task' => task,
66
- 'subtask' => subtask,
67
- 'app' => app,
68
- }.compact
69
- end
70
-
71
- def state_fingerprint
72
- Digest::SHA256.hexdigest(state_key.to_yaml)[0..15]
73
- end
74
-
75
- def short_line(index)
76
- time_str = timestamp ? Time.parse(timestamp).strftime('%Y-%m-%d %H:%M') : Time.now.strftime('%Y-%m-%d %H:%M')
77
- desc = description || cmd || '(no description)'
78
- desc = desc[0..50] + '...' if desc.length > 53
79
- [
80
- format('%3d %s %s', index, time_str, desc),
81
- format(' %s @ %s', git_branch || '(no branch)', task || '(no task)'),
82
- ].join("\n")
83
- end
84
-
85
- def oneline(index = nil)
86
- time_str = timestamp ? Time.parse(timestamp).strftime('%m/%d %H:%M') : ''
87
- prefix = index ? format('%3d ', index) : ''
88
- sha_str = git_sha ? git_sha[0..6] : '-------'
89
- branch_str = git_branch ? "[#{git_branch}]" : ''
90
- task_str = task ? "(#{task})" : ''
91
- cmd_str = cmd || description || ''
92
- cmd_str = cmd_str[0..35] + '...' if cmd_str.length > 38
93
-
94
- "#{prefix}#{time_str} #{sha_str} #{branch_str.ljust(20)} #{task_str.ljust(15)} #{cmd_str}"
95
- end
96
-
97
- def full_display
98
- lines = []
99
- lines << "ID: #{id}"
100
- lines << "Timestamp: #{timestamp}"
101
- lines << "Description: #{description}" if description
102
- lines << "Command: #{cmd}" if cmd
103
- lines << "PWD: #{pwd}" if pwd
104
- lines << "Source: #{source}" if source
105
- lines << ""
106
- lines << "Tmux:"
107
- lines << " Session: #{tmux_session}" if tmux_session
108
- lines << " Window: #{tmux_window}" if tmux_window
109
- lines << " Pane: #{tmux_pane}" if tmux_pane
110
- lines << ""
111
- lines << "Git:"
112
- lines << " Worktree: #{git_worktree}" if git_worktree
113
- lines << " Branch: #{git_branch}" if git_branch
114
- lines << " SHA: #{git_sha}" if git_sha
115
- lines << " Origin: #{git_origin_sha}" if git_origin_sha
116
- lines << ""
117
- lines << "Task:"
118
- lines << " Task: #{task}" if task
119
- lines << " Subtask: #{subtask}" if subtask
120
- lines << " App: #{app}" if app
121
- lines.join("\n")
122
- end
123
-
124
- # Filter matching
125
- def matches?(filters)
126
- filters.all? do |key, value|
127
- entry_value = send(key) rescue nil
128
- next true if value.nil?
129
- next entry_value == value if value.is_a?(String)
130
- next value.include?(entry_value) if value.is_a?(Array)
131
- true
132
- end
133
- end
134
- end
135
- end
136
- end
data/lib/hiiro/history.rb DELETED
@@ -1,454 +0,0 @@
1
- require 'yaml'
2
- require 'time'
3
- require 'fileutils'
4
- require 'digest'
5
- require_relative 'history/entry'
6
-
7
- class Hiiro
8
- class History
9
- HISTORY_DIR = File.join(Dir.home, '.config/hiiro/history')
10
- HISTORY_FILE = File.join(HISTORY_DIR, 'entries.yml')
11
- LAST_STATE_FILE = File.join(HISTORY_DIR, 'last_state.yml')
12
-
13
- class << self
14
- def load(hiiro)
15
- history = new
16
-
17
- hiiro.add_subcmd(:history) do |*args|
18
- subcmd, *rest = args
19
- case subcmd
20
- when 'ls', 'list', nil
21
- history.list(limit: 20)
22
- when 'all'
23
- history.list(limit: nil)
24
- when 'show'
25
- history.show(rest.first)
26
- when 'goto'
27
- history.goto(rest.first)
28
- when 'add'
29
- history.add_manual(rest.join(' '), hiiro: hiiro)
30
- when 'by'
31
- # h history by pane|window|session|task|branch [value]
32
- dimension, value = rest
33
- history.list_by(dimension, value)
34
- when 'clear'
35
- history.clear!
36
- puts "History cleared."
37
- else
38
- puts "Unknown history subcommand: #{subcmd}"
39
- puts "Available: ls, all, show <id>, goto <id>, add <description>, by <dimension> [value], clear"
40
- false
41
- end
42
- end
43
- end
44
-
45
- # Called automatically when a command runs (if in a task context)
46
- def track(cmd:, hiiro: nil)
47
- new.track_command(cmd: cmd, hiiro: hiiro)
48
- end
49
-
50
- def log(description: nil, pwd: Dir.pwd, cmd: nil, source: nil, task: nil, subtask: nil)
51
- new.add(
52
- description: description,
53
- cmd: cmd,
54
- pwd: pwd,
55
- source: source,
56
- task: task,
57
- subtask: subtask
58
- )
59
- end
60
-
61
- # Query helpers for other commands (e.g., h pane history)
62
- def by_pane(pane_id = nil)
63
- pane_id ||= current_tmux_pane
64
- new.filter(tmux_pane: pane_id)
65
- end
66
-
67
- def by_window(window_id = nil)
68
- window_id ||= current_tmux_window
69
- new.filter(tmux_window: window_id)
70
- end
71
-
72
- def by_session(session_name = nil)
73
- session_name ||= current_tmux_session
74
- new.filter(tmux_session: session_name)
75
- end
76
-
77
- def by_task(task_name)
78
- new.filter(task: task_name)
79
- end
80
-
81
- def by_branch(branch_name)
82
- new.filter(git_branch: branch_name)
83
- end
84
-
85
- def by_worktree(worktree_path)
86
- new.filter(git_worktree: worktree_path)
87
- end
88
-
89
- def by_app(app_name)
90
- new.filter(app: app_name)
91
- end
92
-
93
- private
94
-
95
- def current_tmux_pane
96
- return nil unless ENV['TMUX']
97
- `tmux display-message -p '#D'`.strip rescue nil
98
- end
99
-
100
- def current_tmux_window
101
- return nil unless ENV['TMUX']
102
- `tmux display-message -p '#S:#I'`.strip rescue nil
103
- end
104
-
105
- def current_tmux_session
106
- return nil unless ENV['TMUX']
107
- `tmux display-message -p '#S'`.strip rescue nil
108
- end
109
- end
110
-
111
- def initialize
112
- ensure_history_dir
113
- end
114
-
115
- def entries
116
- @entries ||= load_entries
117
- end
118
-
119
- def reload
120
- @entries = nil
121
- entries
122
- end
123
-
124
- def filter(filters = {})
125
- entries.select { |e| e.matches?(filters) }
126
- end
127
-
128
- def list(limit: 20)
129
- items = entries.reverse
130
- items = items.first(limit) if limit
131
-
132
- if items.empty?
133
- puts "No history entries."
134
- return true
135
- end
136
-
137
- items.reverse.each_with_index do |entry, idx|
138
- puts entry.oneline(idx + 1)
139
- end
140
- true
141
- end
142
-
143
- def list_by(dimension, value = nil)
144
- dimension_map = {
145
- 'pane' => :tmux_pane,
146
- 'window' => :tmux_window,
147
- 'session' => :tmux_session,
148
- 'task' => :task,
149
- 'subtask' => :subtask,
150
- 'branch' => :git_branch,
151
- 'worktree' => :git_worktree,
152
- 'app' => :app,
153
- }
154
-
155
- field = dimension_map[dimension]
156
- unless field
157
- puts "Unknown dimension: #{dimension}"
158
- puts "Available: #{dimension_map.keys.join(', ')}"
159
- return false
160
- end
161
-
162
- # If no value specified, use current context
163
- value ||= current_value_for(field)
164
-
165
- if value.nil?
166
- puts "No current #{dimension} detected. Please specify a value."
167
- return false
168
- end
169
-
170
- filtered = filter(field => value)
171
-
172
- if filtered.empty?
173
- puts "No history for #{dimension}=#{value}"
174
- return true
175
- end
176
-
177
- puts "History for #{dimension}: #{value}"
178
- puts
179
- filtered.last(20).each_with_index do |entry, idx|
180
- puts entry.oneline(idx + 1)
181
- end
182
- true
183
- end
184
-
185
- def show(ref)
186
- entry = find_entry(ref)
187
- unless entry
188
- puts "Entry not found: #{ref}"
189
- return false
190
- end
191
-
192
- puts entry.full_display
193
- true
194
- end
195
-
196
- def goto(ref)
197
- entry = find_entry(ref)
198
- unless entry
199
- puts "Entry not found: #{ref}"
200
- return false
201
- end
202
-
203
- unless entry.tmux_session
204
- puts "No tmux session recorded for this entry"
205
- return false
206
- end
207
-
208
- target = entry.tmux_session
209
- target += ":#{entry.tmux_window}" if entry.tmux_window
210
- target += ".#{entry.tmux_pane}" if entry.tmux_pane
211
-
212
- system('tmux', 'switch-client', '-t', target)
213
- end
214
-
215
- def add_manual(description, hiiro: nil)
216
- add(
217
- description: description,
218
- source: 'manual',
219
- cmd: hiiro&.full_command,
220
- )
221
- true
222
- end
223
-
224
- # Main tracking method - gathers all context and saves if changed
225
- def track_command(cmd:, hiiro: nil, force: false)
226
- context = gather_context
227
- return nil unless context[:task] || force # Only track if in a task context (unless forced)
228
-
229
- context[:cmd] = cmd
230
- context[:source] = 'auto'
231
-
232
- # Check if state changed
233
- unless force
234
- new_state = build_state_key(context)
235
- return nil if state_unchanged?(new_state)
236
- save_last_state(new_state)
237
- end
238
-
239
- add(**context)
240
- end
241
-
242
- def add(
243
- description: nil, cmd: nil, source: nil, pwd: nil,
244
- tmux_session: nil, tmux_window: nil, tmux_pane: nil,
245
- git_branch: nil, git_sha: nil, git_origin_sha: nil, git_worktree: nil,
246
- task: nil, subtask: nil, app: nil
247
- )
248
- context = gather_context
249
-
250
- entry_data = {
251
- 'id' => generate_id,
252
- 'timestamp' => Time.now.iso8601,
253
- 'description' => description,
254
- 'cmd' => cmd,
255
- 'pwd' => pwd || context[:pwd],
256
- 'source' => source,
257
- 'tmux_session' => tmux_session || context[:tmux_session],
258
- 'tmux_window' => tmux_window || context[:tmux_window],
259
- 'tmux_pane' => tmux_pane || context[:tmux_pane],
260
- 'git_branch' => git_branch || context[:git_branch],
261
- 'git_sha' => git_sha || context[:git_sha],
262
- 'git_origin_sha' => git_origin_sha || context[:git_origin_sha],
263
- 'git_worktree' => git_worktree || context[:git_worktree],
264
- 'task' => task || context[:task],
265
- 'subtask' => subtask || context[:subtask],
266
- 'app' => app || context[:app],
267
- }.compact
268
-
269
- all = load_raw_entries
270
- all << entry_data
271
- save_entries(all)
272
-
273
- Entry.new(entry_data)
274
- end
275
-
276
- def clear!
277
- save_entries([])
278
- File.delete(LAST_STATE_FILE) if File.exist?(LAST_STATE_FILE)
279
- @entries = nil
280
- end
281
-
282
- private
283
-
284
- def gather_context
285
- {
286
- pwd: Dir.pwd,
287
- tmux_session: current_tmux_session,
288
- tmux_window: current_tmux_window,
289
- tmux_pane: current_tmux_pane,
290
- git_branch: current_git_branch,
291
- git_sha: current_git_sha,
292
- git_origin_sha: current_git_origin_sha,
293
- git_worktree: current_git_worktree,
294
- task: current_task_name,
295
- subtask: current_subtask_name,
296
- app: current_app_name,
297
- }
298
- end
299
-
300
- def build_state_key(context)
301
- context.slice(
302
- :pwd, :tmux_session, :tmux_window, :tmux_pane,
303
- :git_branch, :git_sha, :git_origin_sha, :git_worktree,
304
- :task, :subtask, :app
305
- ).compact
306
- end
307
-
308
- def state_unchanged?(new_state)
309
- return false unless File.exist?(LAST_STATE_FILE)
310
- last_state = YAML.load_file(LAST_STATE_FILE) rescue {}
311
- last_state == new_state.transform_keys(&:to_s)
312
- end
313
-
314
- def save_last_state(state)
315
- File.write(LAST_STATE_FILE, state.transform_keys(&:to_s).to_yaml)
316
- end
317
-
318
- def current_value_for(field)
319
- context = gather_context
320
- context[field]
321
- end
322
-
323
- def find_entry(ref)
324
- return nil unless ref
325
-
326
- if ref.to_s.match?(/^\d+$/)
327
- idx = ref.to_i - 1
328
- reversed = entries.reverse
329
- return reversed[idx] if idx >= 0 && idx < reversed.length
330
- end
331
-
332
- entries.find { |e| e.id == ref.to_s }
333
- end
334
-
335
- def generate_id
336
- Time.now.strftime('%Y%m%d%H%M%S') + '-' + rand(10000).to_s.rjust(4, '0')
337
- end
338
-
339
- def ensure_history_dir
340
- FileUtils.mkdir_p(HISTORY_DIR) unless Dir.exist?(HISTORY_DIR)
341
-
342
- unless File.exist?(HISTORY_FILE)
343
- File.write(HISTORY_FILE, [].to_yaml)
344
- end
345
- end
346
-
347
- def load_raw_entries
348
- YAML.load_file(HISTORY_FILE) || []
349
- rescue => e
350
- []
351
- end
352
-
353
- def load_entries
354
- load_raw_entries.map { |data| Entry.new(data) }
355
- end
356
-
357
- def save_entries(entries_data)
358
- # Keep only last 1000 entries to prevent unbounded growth
359
- entries_data = entries_data.last(1000) if entries_data.length > 1000
360
- File.write(HISTORY_FILE, entries_data.to_yaml)
361
- end
362
-
363
- # Tmux context
364
- def current_tmux_session
365
- return nil unless ENV['TMUX']
366
- `tmux display-message -p '#S'`.strip rescue nil
367
- end
368
-
369
- def current_tmux_window
370
- return nil unless ENV['TMUX']
371
- `tmux display-message -p '#I'`.strip rescue nil
372
- end
373
-
374
- def current_tmux_pane
375
- return nil unless ENV['TMUX']
376
- ENV['TMUX_PANE'] || `tmux display-message -p '#P'`.strip rescue nil
377
- end
378
-
379
- # Git context
380
- def current_git_branch
381
- git_helper.branch_current
382
- end
383
-
384
- def current_git_sha
385
- git_helper.commit('HEAD')
386
- end
387
-
388
- def current_git_origin_sha
389
- branch = current_git_branch
390
- return nil unless branch
391
- git_helper.commit("origin/#{branch}")
392
- end
393
-
394
- def current_git_worktree
395
- git_helper.root
396
- end
397
-
398
- def git_helper
399
- @git_helper ||= Git.new(nil, Dir.pwd)
400
- end
401
-
402
- # Task context
403
- def current_task_name
404
- env = environment
405
- return nil unless env
406
-
407
- task = env.task
408
- return nil unless task
409
-
410
- task.subtask? ? task.parent_name : task.name
411
- end
412
-
413
- def current_subtask_name
414
- env = environment
415
- return nil unless env
416
-
417
- task = env.task
418
- return nil unless task
419
- return nil unless task.subtask?
420
-
421
- task.short_name
422
- end
423
-
424
- def current_app_name
425
- env = environment
426
- return nil unless env
427
-
428
- pwd = Dir.pwd
429
- task = env.task
430
- return nil unless task
431
-
432
- tree = env.find_tree(task.tree_name)
433
- return nil unless tree
434
-
435
- # Check if pwd is in an app directory
436
- env.all_apps.each do |app|
437
- app_path = app.resolve(tree.path)
438
- if pwd == app_path || pwd.start_with?(app_path + '/')
439
- return app.name
440
- end
441
- end
442
-
443
- nil
444
- end
445
-
446
- def environment
447
- @environment ||= begin
448
- Environment.current
449
- rescue NameError
450
- nil
451
- end
452
- end
453
- end
454
- end