hiiro 0.1.307 → 0.1.308.pre.1

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: e00081cfbdc892d4c4e799cbeeb927b6d70139ad18e0b5d6abca846fe3fb1a99
4
- data.tar.gz: 57a103e3413c9370cb3de8968d73aa451fa36f4bcea6ea2d5d1834a56c3fd7c1
3
+ metadata.gz: 5133c5b05e6f2040b092c49a8ff75a1e665f9506d5a6b7afc9078dcd9c3d20a2
4
+ data.tar.gz: bea25caa37de50a810b76b72b8eebe5eb1fd37141542aaea21da1932e3dbae9d
5
5
  SHA512:
6
- metadata.gz: c832c6372c95f54e41e0f4932ac404d85495adb1724838e8b5aed601567b5519a7b9c82e1e60ce095ca8d02963e855d631c8b99ae42cdb785007ea275f5e8635
7
- data.tar.gz: f062a27380003af64dedaf9c4a950744073c255221de17a84b4d01b2879c2bd9697811799e997803c0b347232aab5928b7eaabf1b7c5449e0cd0a52b2deadef1
6
+ metadata.gz: 321291e9a574a533d3647c7c4a54040fdbf993c174923958a4d1fdb7936bee373c838c689ca10fb50b48940d4a9aefc662c4a72714442271c7180eccf82d748f
7
+ data.tar.gz: 15d46bd5f44affa4fb6418f6f76e61cef848a4ade2a975cba450825aeb730da8362fbc0a885241bad31bae4969f752063de95d9143ab72127576850cf3c4e804
data/CHANGELOG.md CHANGED
@@ -1 +1,123 @@
1
- Done. CHANGELOG.md has been updated with v0.1.307 entry at the top, documenting the two recent commits.
1
+ ```markdown
2
+ # Changelog
3
+
4
+ ## [0.1.308.pre.1] - 2026-03-31
5
+
6
+ ### Added
7
+ - `Hiiro::Effects` injectable interface for testable file system and command execution
8
+ - `null_fs` to `TestHarness` for testing without side effects
9
+ - Effects helpers and accessors to `TestHarness` for controlled effect simulation
10
+ - `Hiiro::Invocation` and `Hiiro::InvocationResolution` tracking in PaneHome SQLite migration
11
+
12
+ ### Changed
13
+ - Refactor effects layer: expose `executor` and `fs` as accessors on `Hiiro::Effects`
14
+ - `h-db` command now includes h-pane in SQLite migration
15
+ - Gem version handling: treat non-main branches as pre-release in publish script
16
+
17
+ ### Fixed
18
+ - `h-branch co` and `h-branch rm` restore extra argument pass-through
19
+ - Test suite: add missing `TestHarness` stubs and fix pre-existing test failures
20
+ - Test fixtures: anchor `load_bin` path to project root instead of `Dir.pwd`
21
+
22
+ ### Deprecated
23
+ - `SystemCallCapture` — use `Hiiro::Effects` helpers in `TestHarness` instead
24
+
25
+ ## [Unreleased]
26
+
27
+ ### Added
28
+ - `Hiiro::DB` — SQLite foundation via Sequel; `DB.setup!` creates all tables, `DB.connection` establishes connection eagerly at load time; supports `HIIRO_TEST_DB=sqlite::memory:` for tests
29
+ - `lib/hiiro/db.rb` — one-time YAML→SQLite migration (`migrate_yaml!`) guarded by `schema_migrations` table; dual-write mode (`dual_write?` / `disable_dual_write!`) for gradual cutover
30
+ - `lib/hiiro/branch.rb` — `Hiiro::Branch` Sequel model for worktree branch records
31
+ - `lib/hiiro/tracked_pr.rb` — `Hiiro::TrackedPr` Sequel model for tracked PR records (`:prs` table)
32
+ - `lib/hiiro/link.rb` — `Hiiro::Link` Sequel model with `matches?`, `display_string`, `to_h` helpers
33
+ - `lib/hiiro/project.rb` — `Hiiro::Project` Sequel model
34
+ - `lib/hiiro/pane_home.rb` — `Hiiro::PaneHome` Sequel model with `data_json` JSON blob
35
+ - `lib/hiiro/pin_record.rb` — `Hiiro::PinRecord` Sequel model for per-command key-value pin storage
36
+ - `lib/hiiro/task_record.rb` — `Hiiro::TaskRecord` Sequel model for task metadata
37
+ - `lib/hiiro/app_record.rb` — `Hiiro::AppRecord` Sequel model for app directory mappings
38
+ - `lib/hiiro/assignment.rb` — `Hiiro::Assignment` Sequel model for worktree→branch assignments
39
+ - `lib/hiiro/reminder.rb` — `Hiiro::Reminder` Sequel model
40
+ - `lib/hiiro/invocation.rb` — `Hiiro::Invocation` and `Hiiro::InvocationResolution` Sequel models; every CLI invocation is recorded to SQLite for history/analytics
41
+ - `bin/h-db` — new subcommand: `h db status`, `h db tables`, `h db q <sql>`, `h db migrate`, `h db restore`
42
+
43
+ ### Changed
44
+ - `lib/hiiro/todo.rb` — `TodoItem` is now a `Sequel::Model`; `TodoManager` reads/writes via SQLite with YAML dual-write fallback
45
+ - `lib/hiiro/tags.rb` — `Tag` is now a `Sequel::Model`; tag operations persist to SQLite with YAML dual-write fallback
46
+ - `lib/hiiro/pinned_pr_manager.rb` — `PinnedPR` is now a `Sequel::Model` (`lib/hiiro/pinned_pr.rb`); `PinnedPRManager` reads/writes via SQLite with YAML dual-write
47
+ - `lib/hiiro/projects.rb` — `Projects#from_config` reads from `Hiiro::Project` SQLite model with YAML fallback
48
+ - `lib/hiiro/tasks.rb` — `TaskManager::Config` reads/writes tasks and apps via `Hiiro::TaskRecord` and `Hiiro::AppRecord` SQLite models
49
+ - `bin/h-branch` — `BranchManager` reads/writes via `Hiiro::Branch` and `Hiiro::TrackedPr` SQLite models with YAML dual-write fallback; adds `q`/`query` subcommands for raw SQL inspection
50
+ - `bin/h-link` — reads/writes links via `Hiiro::Link` SQLite model with YAML dual-write fallback; adds `q`/`query` subcommands
51
+ - `bin/h-pane` — load/save pane homes via `Hiiro::PaneHome` model with YAML dual-write
52
+ - `bin/h-pr` — adds `q`/`query` subcommands for inspecting PR records via raw SQL
53
+ - `plugins/pins.rb` — `Pin` class reads/writes via `Hiiro::PinRecord` SQLite model with YAML dual-write fallback
54
+
55
+ ## [0.1.306] - 2026-03-30
56
+
57
+ ### Changed
58
+ - Increase delayed_update sleep duration from 5s to 15s
59
+ - Add logging for delayed_update invocation in publish script
60
+
61
+ ## [0.1.305] - 2026-03-30
62
+
63
+ ### Changed
64
+ - Refactor: use delayed_update subcommand instead of direct update call
65
+ - Improve gem version matching regex in version check
66
+
67
+ ## [0.1.304] - 2026-03-30
68
+
69
+ ### Changed
70
+ - h-notify: use universal log instead of per-session logging
71
+ - Todo output simplified
72
+
73
+ ## [0.1.302] - 2026-03-30
74
+
75
+ ### Fixed
76
+ - Truncate output lines to terminal width in tasks plugin
77
+
78
+ ## [0.1.301]
79
+
80
+ ### Added
81
+ - Check version delayed update functionality
82
+
83
+ ### Changed
84
+ - h-claude: add verbose flags and refactor glob_path handling
85
+
86
+ ### Fixed
87
+ - Use exact session matching to prevent tmux prefix ambiguity
88
+
89
+ ## [0.1.300]
90
+
91
+ ### Added
92
+ - h-claude: fulltext search option for agents/commands/skills
93
+
94
+ ### Changed
95
+ - Refactor h-claude directory traversal and file globbing
96
+
97
+ ## [0.1.299]
98
+
99
+ ### Added
100
+ - h-pr open: support opening multiple PRs
101
+
102
+ ## [0.1.298]
103
+
104
+ ### Changed
105
+ - Use Pathname to walk up directory tree
106
+ - h-claude agents/commands/skills walk from pwd up to home
107
+
108
+ ## [0.1.297]
109
+
110
+ ### Added
111
+ - h rnext subcommand
112
+
113
+ ## [0.1.296]
114
+
115
+ ### Changed
116
+ - Refactor PR filter logic to pinned_pr_manager
117
+ - Move PR filter logic to Pr#matches_filters?
118
+
119
+ ## [0.1.295]
120
+
121
+ ### Changed
122
+ - Filter logic changes for PR management
123
+ ```
data/CLAUDE.md CHANGED
@@ -221,6 +221,38 @@ git.move_worktree(from, to) # Rename worktree
221
221
  git.current_pr # Get current PR info
222
222
  ```
223
223
 
224
+ ### Hiiro::DB (lib/hiiro/db.rb)
225
+
226
+ SQLite persistence layer backed by Sequel. All data is stored in `~/.config/hiiro/hiiro.db`.
227
+
228
+ **Setup:** `Hiiro::DB.setup!` is called at startup — creates any missing tables, then runs a one-time YAML→SQLite migration if the DB is new.
229
+
230
+ **Model registration:** Each Sequel model calls `Hiiro::DB.register(self)` so `setup!` can create its table:
231
+
232
+ ```ruby
233
+ class Hiiro::MyModel < Sequel::Model(:my_table)
234
+ Hiiro::DB.register(self)
235
+
236
+ def self.create_table!(db)
237
+ db.create_table?(:my_table) do
238
+ primary_key :id
239
+ String :name, null: false
240
+ end
241
+ end
242
+ end
243
+ ```
244
+
245
+ **Dual-write:** During rollout, models write to both SQLite and YAML. Once migration is stable, call `Hiiro::DB.disable_dual_write!` to stop YAML writes.
246
+
247
+ **Test isolation:** Set `ENV['HIIRO_TEST_DB'] = 'sqlite::memory:'` before requiring `hiiro` to get a clean in-memory DB per test run. Call `Hiiro::DB.setup!` after require.
248
+
249
+ **`h db` subcommand:** Inspect and manage the database:
250
+ - `h db status` — show connection info and migration state
251
+ - `h db tables` — list all tables with row counts
252
+ - `h db q <sql>` — run raw SQL and print results
253
+ - `h db migrate` — re-run YAML import (if not yet migrated)
254
+ - `h db restore` — restore YAML files from SQLite data
255
+
224
256
  ### Hiiro::Fuzzyfind (lib/hiiro/fuzzyfind.rb)
225
257
 
226
258
  Integration with `sk` (skim) or `fzf` fuzzy finders:
@@ -246,6 +278,26 @@ tm.active # Items not done/skipped
246
278
  tm.filter_by_task("feature") # Items for specific task
247
279
  ```
248
280
 
281
+ ### Invocation Tracking (lib/hiiro/invocation.rb)
282
+
283
+ Every CLI invocation is automatically recorded to SQLite via `Hiiro::Invocation` and `Hiiro::InvocationResolution`. This happens in `Hiiro.init` — no extra setup needed.
284
+
285
+ **Schema:**
286
+ - `Hiiro::Invocation` — records `bin_name`, `argv_json`, `cwd`, `invoked_at`
287
+ - `Hiiro::InvocationResolution` — linked to an invocation; records `resolved_name`, `resolution_type` (exact/prefix/abbreviated), `subcmd`
288
+
289
+ **Query recent invocations:**
290
+ ```ruby
291
+ Hiiro::Invocation.order(Sequel.desc(:invoked_at)).limit(20).each do |inv|
292
+ puts "#{inv.invoked_at} #{inv.bin_name} #{JSON.parse(inv.argv_json).join(' ')}"
293
+ end
294
+ ```
295
+
296
+ Or via `h db q`:
297
+ ```bash
298
+ h db q "SELECT bin_name, argv_json, invoked_at FROM invocations ORDER BY invoked_at DESC LIMIT 10"
299
+ ```
300
+
249
301
  ### Hiiro::Shell (lib/hiiro/shell.rb)
250
302
 
251
303
  Utility for piping content to external commands:
data/bin/h-branch CHANGED
@@ -25,23 +25,20 @@ class BranchManager
25
25
  puts "WARNING: Not in a task session, saving without task info"
26
26
  end
27
27
 
28
- data = load_data
29
- data['branches'] ||= []
30
-
31
- existing = data['branches'].find do |b|
32
- b['name'] == branch_name && b['task'] == entry[:task]
33
- end
34
-
28
+ existing = Hiiro::Branch.find_by_name(branch_name)
35
29
  if existing
36
- existing.merge!(entry.transform_keys(&:to_s))
37
- existing['updated_at'] = Time.now.iso8601
38
30
  puts "Updated branch '#{branch_name}' for task '#{entry[:task]}'"
39
31
  else
40
- data['branches'] << entry.transform_keys(&:to_s).merge('created_at' => Time.now.iso8601)
41
32
  puts "Saved branch '#{branch_name}' for task '#{entry[:task]}'"
42
33
  end
43
34
 
44
- save_data(data)
35
+ save_branch(
36
+ name: entry[:name],
37
+ worktree: entry[:worktree],
38
+ task: entry[:task],
39
+ tmux: entry[:tmux],
40
+ sha: entry[:sha]
41
+ )
45
42
  show_entry(entry)
46
43
  true
47
44
  end
@@ -79,53 +76,58 @@ class BranchManager
79
76
  end
80
77
 
81
78
  def rename_saved(old_name, new_name)
82
- data = load_data
83
- changed = false
84
- (data['branches'] || []).each do |b|
85
- if b['name'] == old_name
86
- b['name'] = new_name
87
- b['updated_at'] = Time.now.iso8601
88
- changed = true
89
- end
90
- end
91
- save_data(data) if changed
92
- changed
79
+ branch = Hiiro::Branch.find_by_name(old_name)
80
+ return false unless branch
81
+ branch.update(name: new_name, updated_at: Time.now.iso8601)
82
+ save_yaml_backup
83
+ true
93
84
  end
94
85
 
95
86
  def remove_saved(branch_name)
96
- data = load_data
97
- before = (data['branches'] || []).length
98
- data['branches'] = (data['branches'] || []).reject { |b| b['name'] == branch_name }
99
- save_data(data) if data['branches'].length < before
87
+ branch = Hiiro::Branch.find_by_name(branch_name)
88
+ return unless branch
89
+ branch.destroy
90
+ save_yaml_backup
100
91
  end
101
92
 
102
93
  def get_note(branch_name = nil)
103
94
  branch_name ||= current_branch
104
- saved_entry(branch_name)&.[]('note')
95
+ Hiiro::Branch.find_by_name(branch_name)&.note
105
96
  end
106
97
 
107
98
  def set_note(text, branch_name = nil)
108
99
  branch_name ||= current_branch
109
- data = load_data
110
- data['branches'] ||= []
111
- entry = data['branches'].find { |b| b['name'] == branch_name }
112
- unless entry
113
- new_entry = build_entry(branch_name).transform_keys(&:to_s).merge('created_at' => Time.now.iso8601)
114
- data['branches'] << new_entry
115
- entry = data['branches'].last
116
- end
117
- if text.nil?
118
- entry.delete('note')
119
- else
120
- entry['note'] = text
121
- end
122
- entry['updated_at'] = Time.now.iso8601
123
- save_data(data)
100
+ branch = Hiiro::Branch.find_by_name(branch_name)
101
+ unless branch
102
+ entry = build_entry(branch_name)
103
+ branch = Hiiro::Branch.create(
104
+ name: branch_name,
105
+ worktree: entry[:worktree],
106
+ task: entry[:task],
107
+ tmux_json: Hiiro::DB::JSON.dump(entry[:tmux]),
108
+ sha: entry[:sha],
109
+ created_at: Time.now.iso8601
110
+ )
111
+ end
112
+ branch.update(note: text, updated_at: Time.now.iso8601)
113
+ save_yaml_backup
124
114
  end
125
115
 
126
116
  def load_data
127
- return {} unless File.exist?(data_file)
128
- YAML.safe_load_file(data_file) || {}
117
+ Hiiro::Branch.ordered.all.each_with_object({ 'branches' => [] }) do |b, data|
118
+ data['branches'] << {
119
+ 'name' => b.name,
120
+ 'worktree' => b.worktree,
121
+ 'task' => b.task,
122
+ 'tmux' => b.tmux.empty? ? nil : b.tmux,
123
+ 'sha' => b.sha,
124
+ 'note' => b.note,
125
+ 'created_at' => b.created_at
126
+ }.compact
127
+ end
128
+ rescue => e
129
+ warn "Branch DB read failed: #{e}"
130
+ load_yaml_fallback
129
131
  end
130
132
 
131
133
  def build_entry(branch_name)
@@ -134,11 +136,11 @@ class BranchManager
134
136
  sha = `git rev-parse HEAD 2>/dev/null`.strip
135
137
 
136
138
  {
137
- name: branch_name,
138
- sha: sha.empty? ? nil : sha,
139
+ name: branch_name,
140
+ sha: sha.empty? ? nil : sha,
139
141
  worktree: current_task&.tree_name,
140
- task: current_task&.name,
141
- tmux: tmux_info
142
+ task: current_task&.name,
143
+ tmux: tmux_info
142
144
  }
143
145
  end
144
146
 
@@ -153,8 +155,8 @@ class BranchManager
153
155
 
154
156
  {
155
157
  'session' => `tmux display-message -p '#S'`.strip,
156
- 'window' => `tmux display-message -p '#W'`.strip,
157
- 'pane' => ENV['TMUX_PANE']
158
+ 'window' => `tmux display-message -p '#W'`.strip,
159
+ 'pane' => ENV['TMUX_PANE']
158
160
  }
159
161
  end
160
162
 
@@ -170,13 +172,46 @@ class BranchManager
170
172
  end
171
173
  end
172
174
 
173
- def data_file
174
- File.join(Dir.home, '.config', 'hiiro', 'branches.yml')
175
+ def save_branch(name:, worktree:, task:, tmux: nil, sha: nil)
176
+ existing = Hiiro::Branch.find_by_name(name)
177
+ if existing
178
+ existing.update(
179
+ worktree: worktree,
180
+ task: task,
181
+ tmux_json: Hiiro::DB::JSON.dump(tmux),
182
+ sha: sha,
183
+ updated_at: Time.now.iso8601
184
+ )
185
+ else
186
+ Hiiro::Branch.create(
187
+ name: name,
188
+ worktree: worktree,
189
+ task: task,
190
+ tmux_json: Hiiro::DB::JSON.dump(tmux),
191
+ sha: sha,
192
+ created_at: Time.now.iso8601
193
+ )
194
+ end
195
+ save_yaml_backup
175
196
  end
176
197
 
177
- def save_data(data)
198
+ def save_yaml_backup
199
+ data = load_data
178
200
  FileUtils.mkdir_p(File.dirname(data_file))
179
201
  File.write(data_file, YAML.dump(data))
202
+ rescue => e
203
+ warn "Branch YAML backup failed: #{e}"
204
+ end
205
+
206
+ def load_yaml_fallback
207
+ return {} unless File.exist?(data_file)
208
+ YAML.safe_load_file(data_file) || {}
209
+ rescue
210
+ {}
211
+ end
212
+
213
+ def data_file
214
+ File.join(Dir.home, '.config', 'hiiro', 'branches.yml')
180
215
  end
181
216
  end
182
217
 
@@ -492,7 +527,7 @@ Hiiro.run(*ARGV) do
492
527
  branch = selected.strip
493
528
  end
494
529
 
495
- system('git', 'checkout', branch)
530
+ system('git', 'checkout', branch, *opts.args[1..])
496
531
  end
497
532
 
498
533
  add_subcmd(:rm, :remove) do |*rm_args|
@@ -510,7 +545,7 @@ Hiiro.run(*ARGV) do
510
545
  branch = selected.strip
511
546
  end
512
547
 
513
- system('git', 'branch', '-d', branch)
548
+ system('git', 'branch', '-d', branch, *opts.args[1..])
514
549
  end
515
550
 
516
551
  add_subcmd(:rename) do |new_name = nil, old_name = nil|
@@ -913,6 +948,48 @@ Hiiro.run(*ARGV) do
913
948
  system('git', 'log', '--oneline', '--decorate', "#{forkpoint}..HEAD")
914
949
  }
915
950
 
951
+ add_subcmd(:q, :query) { |*args|
952
+ def branch_query(args)
953
+ opts = Hiiro::Options.parse(args) { flag(:all, short: :a, desc: 'show all rows (no limit)') }
954
+ query_args = opts.args
955
+ db = Hiiro::DB.connection
956
+ limit = opts.all ? nil : 50
957
+
958
+ if query_args.empty?
959
+ ds = db[:branches]
960
+ ds = ds.limit(limit) if limit
961
+ rows = ds.all
962
+ elsif query_args[0].include?(' ') || query_args[0].upcase =~ /\A(SELECT|INSERT|UPDATE|DELETE|WITH)/
963
+ sql = query_args.join(' ')
964
+ rows = db.fetch(sql).all
965
+ rows = rows.first(limit) if limit
966
+ else
967
+ conditions = query_args.each_with_object({}) do |pair, h|
968
+ k, v = pair.split('=', 2)
969
+ h[k] = v if k && v
970
+ end
971
+ ds = db[:branches]
972
+ conditions.each { |k, v| ds = ds.where(k.to_sym => v) }
973
+ ds = ds.limit(limit) if limit
974
+ rows = ds.all
975
+ end
976
+
977
+ return puts "(no results)" if rows.empty?
978
+ keys = rows.first.keys
979
+ col_widths = keys.map { |k| [k.to_s.length, rows.map { |r| r[k].to_s.length }.max].max.clamp(0, 60) }
980
+ header = keys.each_with_index.map { |k, i| k.to_s.ljust(col_widths[i]) }.join(" ")
981
+ puts header
982
+ puts "-" * header.length
983
+ rows.each do |row|
984
+ puts keys.each_with_index.map { |k, i|
985
+ val = row[k].to_s; val = val[0..57] + "..." if val.length > 60; val.ljust(col_widths[i])
986
+ }.join(" ")
987
+ end
988
+ puts "(#{rows.length} rows)"
989
+ end
990
+ branch_query(args)
991
+ }
992
+
916
993
  add_subcmd(:forkpoint) { |upstream = nil, branch = nil|
917
994
  branch ||= 'HEAD'
918
995
  upstream ||= %w[origin/master master origin/main main].find { |ref|
data/bin/h-db ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'hiiro'
4
+ require 'pathname'
5
+
6
+ def print_table(rows)
7
+ return puts "(no results)" if rows.empty?
8
+
9
+ keys = rows.first.keys
10
+ col_widths = keys.map { |k|
11
+ [k.to_s.length, rows.map { |r| r[k].to_s.length }.max].max.clamp(0, 60)
12
+ }
13
+
14
+ header = keys.each_with_index.map { |k, i| k.to_s.ljust(col_widths[i]) }.join(" ")
15
+ puts header
16
+ puts "-" * header.length
17
+ rows.each do |row|
18
+ puts keys.each_with_index.map { |k, i|
19
+ val = row[k].to_s
20
+ val = val[0..57] + "..." if val.length > 60
21
+ val.ljust(col_widths[i])
22
+ }.join(" ")
23
+ end
24
+ puts "(#{rows.length} rows)"
25
+ end
26
+
27
+ def run_query(args)
28
+ opts = Hiiro::Options.parse(args) { flag(:all, short: :a, desc: 'show all rows (no limit)') }
29
+ query_args = opts.args
30
+
31
+ db = Hiiro::DB.connection
32
+ limit = opts.all ? nil : 50
33
+
34
+ if query_args.empty?
35
+ puts "Usage: h db q <table> [key=value ...] OR h db q \"SQL\""
36
+ return
37
+ elsif query_args[0].include?(' ') || query_args[0].upcase =~ /\A(SELECT|INSERT|UPDATE|DELETE|WITH)/
38
+ sql = query_args.join(' ')
39
+ rows = db.fetch(sql).all
40
+ rows = rows.first(limit) if limit
41
+ print_table(rows)
42
+ else
43
+ table = query_args[0]
44
+ conditions = query_args[1..].each_with_object({}) do |pair, h|
45
+ k, v = pair.split('=', 2)
46
+ h[k] = v if k && v
47
+ end
48
+ ds = db[table.to_sym]
49
+ conditions.each { |k, v| ds = ds.where(k.to_sym => v) }
50
+ ds = ds.limit(limit) if limit
51
+ rows = ds.all
52
+ print_table(rows)
53
+ end
54
+ end
55
+
56
+ Hiiro.run(*ARGV) do
57
+ add_subcmd(:status) do
58
+ db_path = Hiiro::DB::DB_FILE
59
+ db_size = File.exist?(db_path) ? File.size(db_path) : 0
60
+ db_size_str = if db_size >= 1_048_576
61
+ "#{(db_size / 1_048_576.0).round(1)} MB"
62
+ elsif db_size >= 1024
63
+ "#{(db_size / 1024.0).round(1)} KB"
64
+ else
65
+ "#{db_size} B"
66
+ end
67
+
68
+ puts "DB: #{db_path} (#{db_size_str})"
69
+
70
+ migrated = Hiiro::DB.migrated?
71
+ if migrated
72
+ ran_at = begin
73
+ Hiiro::DB.connection[:schema_migrations].where(name: 'yaml_import').first&.[](:ran_at)
74
+ rescue
75
+ nil
76
+ end
77
+ puts "Migration: complete#{ran_at ? " (#{ran_at})" : ""}"
78
+ else
79
+ puts "Migration: not run"
80
+ end
81
+
82
+ puts "Dual-write: #{Hiiro::DB.dual_write? ? 'active' : 'disabled'}"
83
+ puts
84
+
85
+ db = Hiiro::DB.connection
86
+ tables = db.tables - [:schema_migrations]
87
+ if tables.any?
88
+ puts "Table counts:"
89
+ max_len = tables.map(&:to_s).map(&:length).max
90
+ tables.sort.each do |t|
91
+ count = db[t].count rescue '?'
92
+ puts " #{t.to_s.ljust(max_len)} #{count.to_s.rjust(6)}"
93
+ end
94
+ end
95
+
96
+ config_dir = File.expand_path('~/.config/hiiro')
97
+ archives = Dir.glob(File.join(config_dir, 'sqlite-migration.*.tar.gz')).sort
98
+ puts
99
+ if archives.empty?
100
+ puts "Backups: (none)"
101
+ else
102
+ puts "Backups:"
103
+ archives.each { |a| puts " #{a}" }
104
+ end
105
+ end
106
+
107
+ add_subcmd(:tables) do
108
+ Hiiro::DB.connection.tables.sort.each { |t| puts t }
109
+ end
110
+
111
+ add_subcmd(:q) { |*args| run_query(args) }
112
+ add_subcmd(:query) { |*args| run_query(args) }
113
+
114
+ add_subcmd(:migrate) do
115
+ unless Hiiro::DB.migrated?
116
+ puts "Migration has not run yet. Run `h db status` for details."
117
+ next
118
+ end
119
+
120
+ unless Hiiro::DB.dual_write?
121
+ puts "Already fully migrated. Dual-write is already disabled."
122
+ next
123
+ end
124
+
125
+ print "This will archive and remove all YAML files. Continue? [y/N] "
126
+ response = $stdin.gets.chomp
127
+ unless response.downcase == 'y'
128
+ puts "Aborted."
129
+ next
130
+ end
131
+
132
+ config_dir = File.expand_path('~/.config/hiiro')
133
+ timestamp = Time.now.strftime('%Y%m%d%H%M%S')
134
+ archive = File.join(config_dir, "sqlite-migration.#{timestamp}.tar.gz")
135
+
136
+ yml_files = Dir.glob(File.join(config_dir, '**/*.yml'))
137
+ .reject { |f| f.end_with?('.yml.bak') }
138
+ .map { |f| Pathname.new(f).relative_path_from(config_dir).to_s }
139
+
140
+ if yml_files.empty?
141
+ puts "No YAML files found to back up."
142
+ else
143
+ puts "Creating backup: #{archive}"
144
+ Dir.chdir(config_dir) do
145
+ system('tar', 'cvzf', archive, *yml_files)
146
+ end
147
+ puts "\nRemoving YAML files..."
148
+ yml_files.each do |rel_path|
149
+ full_path = File.join(config_dir, rel_path)
150
+ puts " rm #{full_path}"
151
+ File.delete(full_path)
152
+ end
153
+ end
154
+
155
+ Hiiro::DB.disable_dual_write!
156
+
157
+ puts
158
+ puts "Full migration complete."
159
+ puts "Backup saved: #{archive}"
160
+ puts
161
+ puts "To restore YAML files:"
162
+ puts " cd #{config_dir}"
163
+ puts " tar xvzf #{File.basename(archive)}"
164
+ end
165
+
166
+ add_subcmd(:restore) do
167
+ config_dir = File.expand_path('~/.config/hiiro')
168
+ archives = Dir.glob(File.join(config_dir, 'sqlite-migration.*.tar.gz')).sort
169
+ if archives.empty?
170
+ puts "No backup archives found in #{config_dir}"
171
+ next
172
+ end
173
+ latest = archives.last
174
+ puts "Restoring from #{File.basename(latest)}..."
175
+ Dir.chdir(config_dir) { system('tar', 'xvzf', latest) }
176
+ puts "Done. YAML files restored."
177
+ end
178
+ end