izar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +45 -0
- data/Rakefile +8 -0
- data/exe/izar +6 -0
- data/lib/izar/version.rb +5 -0
- data/lib/izar.rb +666 -0
- data/sig/izar.rbs +16 -0
- metadata +157 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: adbd281a78d48b4fae7001785c8df64cd4f933e081640edf72b41b1415d86b69
|
|
4
|
+
data.tar.gz: 42ff12350941b4c5667cac792f575bb640e11a4ab3daaa2fa60a34deed7bd7a9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9213ccc523c8b98d3ef4364e63303ee3c3f6c297e52828992d4a9fb6b5909e688a30650ec3cdfb7571c24d823392451636ebe33b52dc925c5165fcb19dd6f3dc
|
|
7
|
+
data.tar.gz: b34709d071003f1fb7bb9dcbc1677a69ab13ae0042f99b0145b7dd296ddc26b4eaabb619247223864432b7ba0e3d177ed6dcc263a365d6983677522f25d3258b
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Izar
|
|
2
|
+
|
|
3
|
+
Pure Ruby Git staging workspace built on Thuban and Porrima. It shows staged,
|
|
4
|
+
unstaged, and untracked files, computes safe hunks, and keeps destructive
|
|
5
|
+
discard operations behind an explicit confirmation flag.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install izar
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Run inside a Git repository:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
izar status
|
|
19
|
+
izar stage lib/example.rb
|
|
20
|
+
izar unstage lib/example.rb
|
|
21
|
+
izar commit -m "Describe the change"
|
|
22
|
+
izar discard tmp.txt --yes
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The Ruby API is intentionally small:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
repo = Izar::Repository.new
|
|
29
|
+
repo.status
|
|
30
|
+
repo.diff("README.md").staged_to_worktree
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Izar does not invoke the `git` executable.
|
|
34
|
+
|
|
35
|
+
## Development
|
|
36
|
+
|
|
37
|
+
Run `rake spec` and `gem build --strict izar.gemspec`.
|
|
38
|
+
|
|
39
|
+
## Contributing
|
|
40
|
+
|
|
41
|
+
Bug reports and pull requests are welcome at https://github.com/noxdea/izar.
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT.
|
data/Rakefile
ADDED
data/exe/izar
ADDED
data/lib/izar/version.rb
ADDED
data/lib/izar.rb
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "thuban"
|
|
6
|
+
require "porrima"
|
|
7
|
+
begin
|
|
8
|
+
require "kochab"
|
|
9
|
+
rescue LoadError
|
|
10
|
+
end
|
|
11
|
+
begin
|
|
12
|
+
require "spica"
|
|
13
|
+
rescue LoadError
|
|
14
|
+
end
|
|
15
|
+
begin
|
|
16
|
+
require "zaniah"
|
|
17
|
+
require "zaniah/ui"
|
|
18
|
+
rescue LoadError
|
|
19
|
+
end
|
|
20
|
+
begin
|
|
21
|
+
require "auva"
|
|
22
|
+
rescue LoadError
|
|
23
|
+
end
|
|
24
|
+
require_relative "izar/version"
|
|
25
|
+
|
|
26
|
+
module Izar
|
|
27
|
+
class Error < StandardError; end
|
|
28
|
+
|
|
29
|
+
class Operation
|
|
30
|
+
attr_reader :error
|
|
31
|
+
|
|
32
|
+
def initialize(&block)
|
|
33
|
+
@status = :running
|
|
34
|
+
@thread = Thread.new do
|
|
35
|
+
block.call
|
|
36
|
+
rescue StandardError => error
|
|
37
|
+
@error = error
|
|
38
|
+
ensure
|
|
39
|
+
@status = :done
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def running? = @status == :running
|
|
44
|
+
def done? = @status == :done
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Change = Data.define(:path, :code, :index, :worktree) do
|
|
48
|
+
def staged? = index != " " && index != "?"
|
|
49
|
+
def untracked? = index == "?"
|
|
50
|
+
def unstaged? = worktree != " "
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class Repository
|
|
54
|
+
attr_reader :repo
|
|
55
|
+
|
|
56
|
+
def initialize(dir = Dir.pwd)
|
|
57
|
+
@repo = Thuban::Repository.new(dir)
|
|
58
|
+
rescue ArgumentError => error
|
|
59
|
+
raise Error, error.message
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def root = repo.root
|
|
63
|
+
def branch = repo.branch || "(detached)"
|
|
64
|
+
def head = repo.head
|
|
65
|
+
|
|
66
|
+
def ahead_behind
|
|
67
|
+
return {ahead: 0, behind: 0} unless repo.branch && head
|
|
68
|
+
upstream = repo.resolve("refs/remotes/origin/#{repo.branch}")
|
|
69
|
+
return {ahead: 0, behind: 0} unless upstream
|
|
70
|
+
base = repo.merge_base(head, upstream)
|
|
71
|
+
{ahead: count_until(head, base), behind: count_until(upstream, base)}
|
|
72
|
+
rescue StandardError
|
|
73
|
+
{ahead: 0, behind: 0}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def status
|
|
77
|
+
repo.status.map { |entry| Change.new(path: entry.path, code: entry.code, index: entry.index, worktree: entry.worktree) }
|
|
78
|
+
rescue StandardError => error
|
|
79
|
+
raise Error, error.message
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def grouped(filter: nil)
|
|
83
|
+
changes = status
|
|
84
|
+
if filter && !filter.empty?
|
|
85
|
+
candidates = changes.map(&:path)
|
|
86
|
+
matches = defined?(Spica) ? Spica.filter(filter, candidates).map(&:candidate) : candidates.select { |path| path.include?(filter) }
|
|
87
|
+
changes = changes.select { |change| matches.include?(change.path) }
|
|
88
|
+
end
|
|
89
|
+
{
|
|
90
|
+
staged: changes.select(&:staged?),
|
|
91
|
+
unstaged: changes.select { |change| change.unstaged? && !change.untracked? },
|
|
92
|
+
untracked: changes.select(&:untracked?)
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stage(path)
|
|
97
|
+
content = repo.worktree_content(path)
|
|
98
|
+
if content
|
|
99
|
+
stage_content(path, content)
|
|
100
|
+
else
|
|
101
|
+
index = repo.index
|
|
102
|
+
index.remove(path)
|
|
103
|
+
index.write
|
|
104
|
+
end
|
|
105
|
+
path
|
|
106
|
+
rescue StandardError => error
|
|
107
|
+
raise Error, error.message
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def stage_content(path, content)
|
|
111
|
+
index = repo.index
|
|
112
|
+
entry = index[path]
|
|
113
|
+
stat = File.stat(repo.worktree_path(path)) rescue nil
|
|
114
|
+
mode = entry&.mode || (stat && (stat.mode & 0o100).positive? ? 0o100755 : 0o100644)
|
|
115
|
+
index.stage(path, repo.write_blob(content), mode, stat: stat)
|
|
116
|
+
index.write
|
|
117
|
+
path
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def unstage(path)
|
|
121
|
+
index = repo.index
|
|
122
|
+
head_content = repo.blob(path)
|
|
123
|
+
if head_content
|
|
124
|
+
entry = repo.tree.fetch(path)
|
|
125
|
+
stat = File.stat(repo.worktree_path(path)) rescue nil
|
|
126
|
+
index.stage(path, entry.oid, entry.mode, stat: stat)
|
|
127
|
+
else
|
|
128
|
+
index.remove(path)
|
|
129
|
+
end
|
|
130
|
+
index.write
|
|
131
|
+
path
|
|
132
|
+
rescue StandardError => error
|
|
133
|
+
raise Error, error.message
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def discard(path, confirm: false)
|
|
137
|
+
raise Error, "discard requires confirmation" unless confirm
|
|
138
|
+
backup = repo.stash_push(message: "izar discard #{path}", include_untracked: true)
|
|
139
|
+
repo.stash_pop if backup
|
|
140
|
+
head_content = repo.blob(path)
|
|
141
|
+
absolute = repo.worktree_path(path)
|
|
142
|
+
if head_content
|
|
143
|
+
FileUtils.mkdir_p(File.dirname(absolute))
|
|
144
|
+
File.binwrite(absolute, head_content)
|
|
145
|
+
entry = repo.tree.fetch(path)
|
|
146
|
+
File.chmod(entry.mode & 0o777, absolute) unless entry.mode == 0o120000
|
|
147
|
+
index = repo.index
|
|
148
|
+
index.stage(path, entry.oid, entry.mode, stat: File.stat(absolute))
|
|
149
|
+
index.write
|
|
150
|
+
else
|
|
151
|
+
File.unlink(absolute) if File.file?(absolute) || File.symlink?(absolute)
|
|
152
|
+
index = repo.index
|
|
153
|
+
index.remove(path)
|
|
154
|
+
index.write
|
|
155
|
+
end
|
|
156
|
+
{path: path, backup: backup, message: backup ? "temporary stash #{backup} was restored before discarding #{path}" : "discarded #{path}"}
|
|
157
|
+
rescue StandardError => error
|
|
158
|
+
raise Error, error.message
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def commit(message)
|
|
162
|
+
message = message.to_s.strip
|
|
163
|
+
raise Error, "commit message cannot be empty" if message.empty?
|
|
164
|
+
raise Error, "nothing staged" unless status.any?(&:staged?)
|
|
165
|
+
repo.commit!(message: message, author: repo.signature(role: :author), committer: repo.signature(role: :committer))
|
|
166
|
+
rescue StandardError => error
|
|
167
|
+
raise Error, error.message
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def diff(path, context: 3) = Diff.new(self, path, context: context)
|
|
171
|
+
|
|
172
|
+
def stage_all
|
|
173
|
+
status.each { |change| stage(change.path) unless change.staged? && !change.unstaged? }
|
|
174
|
+
self
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def count_until(reference, stop)
|
|
180
|
+
repo.each_commit(reference, limit: 100_000).take_while { |commit| commit.oid != stop }.length
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
class Diff
|
|
185
|
+
attr_reader :repository, :path, :context
|
|
186
|
+
|
|
187
|
+
def initialize(repository, path, context: 3)
|
|
188
|
+
@repository, @path, @context = repository, path, context
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def staged_to_worktree
|
|
192
|
+
Porrima.diff(repository.repo.staged_blob(path).to_s, repository.repo.worktree_content(path).to_s, context: context).hunks
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def head_to_staged
|
|
196
|
+
Porrima.diff(repository.repo.blob(path).to_s, repository.repo.staged_blob(path).to_s, context: context).hunks
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def stage_hunk(hunk)
|
|
200
|
+
before = repository.repo.staged_blob(path).to_s
|
|
201
|
+
after = Porrima.apply(before, hunk)
|
|
202
|
+
repository.stage_content(path, after)
|
|
203
|
+
after
|
|
204
|
+
rescue StandardError => error
|
|
205
|
+
raise Error, "cannot stage hunk: #{error.message}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def unstage_hunk(hunk)
|
|
209
|
+
before = repository.repo.staged_blob(path).to_s
|
|
210
|
+
after = Porrima.revert(before, hunk)
|
|
211
|
+
if after.empty? && repository.repo.blob(path).nil?
|
|
212
|
+
index = repository.repo.index
|
|
213
|
+
index.remove(path)
|
|
214
|
+
index.write
|
|
215
|
+
else
|
|
216
|
+
repository.stage_content(path, after)
|
|
217
|
+
end
|
|
218
|
+
after
|
|
219
|
+
rescue StandardError => error
|
|
220
|
+
raise Error, "cannot unstage hunk: #{error.message}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def lines(limit: 50_000)
|
|
224
|
+
staged_to_worktree.flat_map { |hunk| hunk.edits.map { |edit| [edit.kind, edit.text] } }.first(limit)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def highlight(lines: nil, limit: 50_000)
|
|
228
|
+
source = Array(lines || repository.repo.worktree_content(path).to_s.lines).map do |line|
|
|
229
|
+
line.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
230
|
+
end
|
|
231
|
+
require "antares"
|
|
232
|
+
require "rouge"
|
|
233
|
+
lexer = Rouge::Lexer.guess(filename: path)
|
|
234
|
+
highlighter = Antares::Highlighter.new(lexer: lexer, lines: ->(index) { source[index] }, line_count: -> { source.length })
|
|
235
|
+
highlighter.tokens_in(0...[source.length, limit].min)
|
|
236
|
+
rescue LoadError, StandardError
|
|
237
|
+
source.to_a.first(limit).map { |line| [[nil, line]] }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
class Model
|
|
242
|
+
attr_reader :repository, :selected_path, :query, :config, :message, :operation
|
|
243
|
+
|
|
244
|
+
def initialize(dir = Dir.pwd, config: Config.load(File.join(dir, ".izar.jsonc")))
|
|
245
|
+
@repository = Repository.new(dir)
|
|
246
|
+
@config = config
|
|
247
|
+
@selected_path = nil
|
|
248
|
+
@query = ""
|
|
249
|
+
@message = nil
|
|
250
|
+
@operation = nil
|
|
251
|
+
reload
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def reload
|
|
255
|
+
@groups = repository.grouped(filter: query)
|
|
256
|
+
paths = @groups.values.flatten.map(&:path)
|
|
257
|
+
@selected_path = paths.include?(selected_path) ? selected_path : paths.first
|
|
258
|
+
self
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def groups = @groups
|
|
262
|
+
def select(path) = (@selected_path = path)
|
|
263
|
+
def filter(value)
|
|
264
|
+
@query = value.to_s
|
|
265
|
+
reload
|
|
266
|
+
end
|
|
267
|
+
def selected = groups.values.flatten.find { |change| change.path == selected_path }
|
|
268
|
+
|
|
269
|
+
def paths = groups.values.flatten.map(&:path)
|
|
270
|
+
|
|
271
|
+
def move(delta)
|
|
272
|
+
return nil if paths.empty?
|
|
273
|
+
index = [paths.index(selected_path).to_i + delta, 0].max
|
|
274
|
+
select(paths[[index, paths.length - 1].min])
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def diff
|
|
278
|
+
return nil unless selected_path
|
|
279
|
+
context = config.dig("diff", "context").to_i
|
|
280
|
+
repository.diff(selected_path, context: context.positive? ? context : 3)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def stage_selected(async: false)
|
|
284
|
+
return reload unless selected_path
|
|
285
|
+
perform(async) { repository.stage(selected_path); reload }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def unstage_selected(async: false)
|
|
289
|
+
return reload unless selected_path
|
|
290
|
+
perform(async) { repository.unstage(selected_path); reload }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def toggle_selected(async: false)
|
|
294
|
+
return reload unless selected
|
|
295
|
+
selected.staged? && !selected.unstaged? ? unstage_selected(async: async) : stage_selected(async: async)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def stage_selected_hunk(async: false)
|
|
299
|
+
change = selected
|
|
300
|
+
return reload unless change
|
|
301
|
+
perform(async) do
|
|
302
|
+
if change.staged? && !change.unstaged?
|
|
303
|
+
hunks = repository.diff(change.path).head_to_staged
|
|
304
|
+
repository.diff(change.path).unstage_hunk(hunks.first) if hunks.first
|
|
305
|
+
else
|
|
306
|
+
hunks = repository.diff(change.path).staged_to_worktree
|
|
307
|
+
repository.diff(change.path).stage_hunk(hunks.first) if hunks.first
|
|
308
|
+
end
|
|
309
|
+
reload
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def stage_all(async: false)
|
|
314
|
+
perform(async) { repository.stage_all; reload }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def discard_selected(async: false)
|
|
318
|
+
return reload unless selected_path
|
|
319
|
+
perform(async) { repository.discard(selected_path, confirm: true); reload }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def commit(message, async: false)
|
|
323
|
+
perform(async) { repository.commit(message); reload }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def busy?
|
|
327
|
+
operation&.running? || false
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def poll_operation
|
|
331
|
+
return false unless operation&.done?
|
|
332
|
+
@message = operation.error.message if operation.error
|
|
333
|
+
@operation = nil
|
|
334
|
+
true
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def message=(value)
|
|
338
|
+
@message = value
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
private
|
|
342
|
+
|
|
343
|
+
def perform(async)
|
|
344
|
+
return yield unless async
|
|
345
|
+
return false if busy?
|
|
346
|
+
@operation = Operation.new { yield }
|
|
347
|
+
true
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
module Config
|
|
352
|
+
DEFAULTS = {"theme" => "dark", "split_ratio" => 0.35,
|
|
353
|
+
"keymap" => {"stage" => "space", "commit" => "c"},
|
|
354
|
+
"diff" => {"context" => 3, "highlight" => true}}.freeze
|
|
355
|
+
module_function
|
|
356
|
+
|
|
357
|
+
def load(path = File.join(Dir.pwd, ".izar.jsonc"))
|
|
358
|
+
return DEFAULTS unless File.file?(path)
|
|
359
|
+
value = Kochab.parse(File.read(path, encoding: "UTF-8")).value
|
|
360
|
+
raise Error, "Izar config must be an object" unless value.is_a?(Hash)
|
|
361
|
+
config = DEFAULTS.merge(value) do |_key, defaults, override|
|
|
362
|
+
defaults.is_a?(Hash) && override.is_a?(Hash) ? defaults.merge(override) : override
|
|
363
|
+
end
|
|
364
|
+
validate(config)
|
|
365
|
+
config
|
|
366
|
+
rescue Kochab::ParseError => error
|
|
367
|
+
raise Error, "invalid config: #{error.message}"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def validate(config)
|
|
371
|
+
unknown = config.keys.map(&:to_s) - DEFAULTS.keys
|
|
372
|
+
raise Error, "unknown config key: #{unknown.first}" unless unknown.empty?
|
|
373
|
+
raise Error, "theme must be a string" unless config["theme"].is_a?(String)
|
|
374
|
+
ratio = config["split_ratio"]
|
|
375
|
+
raise Error, "split_ratio must be between 0 and 1" unless ratio.is_a?(Numeric) && ratio.finite? && ratio.between?(0, 1)
|
|
376
|
+
keymap = config["keymap"]
|
|
377
|
+
raise Error, "keymap must be an object" unless keymap.is_a?(Hash)
|
|
378
|
+
%w[stage commit].each do |key|
|
|
379
|
+
value = keymap[key]
|
|
380
|
+
raise Error, "keymap.#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
381
|
+
end
|
|
382
|
+
unknown_keymap = keymap.keys.map(&:to_s) - DEFAULTS["keymap"].keys
|
|
383
|
+
raise Error, "unknown keymap key: #{unknown_keymap.first}" unless unknown_keymap.empty?
|
|
384
|
+
diff = config["diff"]
|
|
385
|
+
raise Error, "diff must be an object" unless diff.is_a?(Hash)
|
|
386
|
+
context = diff["context"]
|
|
387
|
+
raise Error, "diff.context must be a non-negative integer" unless context.is_a?(Integer) && context >= 0
|
|
388
|
+
raise Error, "diff.highlight must be boolean" unless diff["highlight"] == true || diff["highlight"] == false
|
|
389
|
+
unknown_diff = diff.keys.map(&:to_s) - DEFAULTS["diff"].keys
|
|
390
|
+
raise Error, "unknown diff key: #{unknown_diff.first}" unless unknown_diff.empty?
|
|
391
|
+
config
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
module Theme
|
|
396
|
+
module_function
|
|
397
|
+
|
|
398
|
+
def resolve(value)
|
|
399
|
+
return value if value.respond_to?(:colors)
|
|
400
|
+
return Auva.load(value) if defined?(Auva) && File.file?(value.to_s)
|
|
401
|
+
return Auva.builtin(value) if defined?(Auva)
|
|
402
|
+
Zaniah::Theme.public_send(value.to_s)
|
|
403
|
+
rescue StandardError
|
|
404
|
+
raise Error, "unknown theme: #{value}"
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
module TUI
|
|
409
|
+
module_function
|
|
410
|
+
|
|
411
|
+
def keymap(config = Config::DEFAULTS)
|
|
412
|
+
return nil unless defined?(Zaniah::Input::Keymap)
|
|
413
|
+
bindings = config.fetch("keymap", {})
|
|
414
|
+
Zaniah::Input::Keymap.new
|
|
415
|
+
.bind("j", :next)
|
|
416
|
+
.bind("k", :previous)
|
|
417
|
+
.bind(bindings.fetch("stage", "space"), :stage)
|
|
418
|
+
.bind("a", :stage_all)
|
|
419
|
+
.bind("s", :hunk)
|
|
420
|
+
.bind(bindings.fetch("commit", "c"), :commit)
|
|
421
|
+
.bind("X", :discard)
|
|
422
|
+
.bind("/", :filter)
|
|
423
|
+
.bind("r", :reload)
|
|
424
|
+
.bind("?", :help)
|
|
425
|
+
.bind("q", :quit)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
class Session
|
|
429
|
+
attr_reader :model, :message
|
|
430
|
+
|
|
431
|
+
def initialize(model, async: false)
|
|
432
|
+
@model = model
|
|
433
|
+
@async = async
|
|
434
|
+
@keymap = TUI.keymap(model.config)
|
|
435
|
+
@fallback = {"j" => :next, "k" => :previous,
|
|
436
|
+
model.config.dig("keymap", "stage").to_s => :stage,
|
|
437
|
+
"a" => :stage_all, "s" => :hunk, "X" => :discard,
|
|
438
|
+
model.config.dig("keymap", "commit").to_s => :commit,
|
|
439
|
+
"/" => :filter, "r" => :reload, "?" => :help, "q" => :quit}
|
|
440
|
+
@fallback[" "] ||= :stage
|
|
441
|
+
@message = nil
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def dispatch(key, commit_message: nil, confirm: false, filter_text: nil)
|
|
445
|
+
action = @keymap&.dispatch(key) || @fallback[key]
|
|
446
|
+
case action
|
|
447
|
+
when :next then model.move(1)
|
|
448
|
+
when :previous then model.move(-1)
|
|
449
|
+
when :stage then model.toggle_selected(async: @async)
|
|
450
|
+
when :stage_all then model.stage_all(async: @async)
|
|
451
|
+
when :hunk then model.stage_selected_hunk(async: @async)
|
|
452
|
+
when :discard
|
|
453
|
+
confirm ? model.discard_selected(async: @async) : (@message = "discard cancelled")
|
|
454
|
+
when :filter
|
|
455
|
+
model.filter(filter_text.to_s)
|
|
456
|
+
when :commit
|
|
457
|
+
begin
|
|
458
|
+
model.commit(commit_message, async: @async)
|
|
459
|
+
rescue Error => error
|
|
460
|
+
@message = error.message
|
|
461
|
+
end
|
|
462
|
+
when :reload then model.reload
|
|
463
|
+
when :help then @message = "j/k move · space stage · a all · s hunk · X discard · c commit · q quit"
|
|
464
|
+
when :quit then return :quit
|
|
465
|
+
end
|
|
466
|
+
model.message = @message
|
|
467
|
+
action
|
|
468
|
+
rescue Error => error
|
|
469
|
+
@message = error.message
|
|
470
|
+
model.message = @message
|
|
471
|
+
:error
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def run(model, input: $stdin, output: $stdout)
|
|
476
|
+
unless input.tty? && output.tty? && defined?(Zaniah::Platform)
|
|
477
|
+
View.render(model, out: output)
|
|
478
|
+
return 0
|
|
479
|
+
end
|
|
480
|
+
require "io/console"
|
|
481
|
+
session = Session.new(model)
|
|
482
|
+
input.raw do
|
|
483
|
+
loop do
|
|
484
|
+
output.write("\e[2J\e[H")
|
|
485
|
+
View.render(model, out: output)
|
|
486
|
+
key = input.getch
|
|
487
|
+
if key == "c"
|
|
488
|
+
output.write("commit message: ")
|
|
489
|
+
message = input.cooked { input.gets.to_s.chomp }
|
|
490
|
+
result = session.dispatch(key, commit_message: message)
|
|
491
|
+
elsif key == "X"
|
|
492
|
+
output.write("discard #{model.selected_path}? [y/N] ")
|
|
493
|
+
confirm = input.cooked { input.getch.to_s.downcase == "y" }
|
|
494
|
+
result = session.dispatch(key, confirm: confirm)
|
|
495
|
+
elsif key == "/"
|
|
496
|
+
output.write("filter: ")
|
|
497
|
+
query = input.cooked { input.gets.to_s.chomp }
|
|
498
|
+
result = session.dispatch(key, filter_text: query)
|
|
499
|
+
else
|
|
500
|
+
result = session.dispatch(key)
|
|
501
|
+
end
|
|
502
|
+
break if result == :quit
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
0
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def snapshot(model)
|
|
509
|
+
model.groups.map { |name, entries| "#{name}: #{entries.map(&:path).join(", ")}" }.join("\n")
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
module View
|
|
514
|
+
module_function
|
|
515
|
+
|
|
516
|
+
def element(model)
|
|
517
|
+
theme = defined?(Zaniah::Theme) ? Theme.resolve(model.config.fetch("theme", "dark")) : nil
|
|
518
|
+
return Zaniah::UI::EmptyState.new("No changes", message: "Working tree is clean") unless theme
|
|
519
|
+
|
|
520
|
+
sidebar = Zaniah::UI::Sidebar.new(width: 280)
|
|
521
|
+
model.groups.each do |name, entries|
|
|
522
|
+
sidebar.child(Zaniah::UI::Label.new("#{name.to_s.capitalize} (#{entries.length})", tone: :muted, size: :xs))
|
|
523
|
+
entries.each do |change|
|
|
524
|
+
variant = change.path == model.selected_path ? :secondary : :ghost
|
|
525
|
+
sidebar.child(Zaniah::UI::Button.new("#{change.code} #{change.path}", size: :sm, variant: variant).w_full
|
|
526
|
+
.on_click { |_event, context| model.select(change.path); context.window.request_frame })
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
main = Zaniah::Div.new.flex_col.gap(12).p(24).bg(theme.colors.background)
|
|
531
|
+
status_children = [Zaniah::UI::Label.new("#{model.repository.branch} · #{model.groups.values.sum(&:length)} changes", size: :sm)]
|
|
532
|
+
status_children << Zaniah::UI::Spinner.new(size: 14) if model.busy?
|
|
533
|
+
status_children << Zaniah::UI::Spacer.new
|
|
534
|
+
status_children << Zaniah::UI::Label.new(model.message.to_s, tone: :muted, size: :xs)
|
|
535
|
+
main.child(Zaniah::UI::StatusBar.new(*status_children))
|
|
536
|
+
if model.selected_path && model.diff
|
|
537
|
+
lines = model.diff.lines(limit: 50_000)
|
|
538
|
+
main.child(Zaniah::UI::Label.new("Diff: #{model.selected_path}", size: :lg))
|
|
539
|
+
main.child(Zaniah::List.new(count: lines.length, estimated_height: 20) do |index|
|
|
540
|
+
kind, text = lines[index]
|
|
541
|
+
Zaniah::Div.new.h(20).child(Zaniah::UI::RichText.new(diff_runs(model, kind, text, theme), selectable: false))
|
|
542
|
+
end.h(680))
|
|
543
|
+
else
|
|
544
|
+
main.child(Zaniah::UI::EmptyState.new("Select a change", message: "Choose a file from the sidebar"))
|
|
545
|
+
end
|
|
546
|
+
Zaniah::UI::SplitPane.new(sidebar, main, ratio: model.config.fetch("split_ratio", 0.35))
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def render(model, out: $stdout)
|
|
550
|
+
ahead = model.repository.ahead_behind
|
|
551
|
+
out.puts "#{model.repository.branch} ↑#{ahead[:ahead]} ↓#{ahead[:behind]} · #{model.groups.values.sum(&:length)} changes"
|
|
552
|
+
{staged: "Staged", unstaged: "Unstaged", untracked: "Untracked"}.each do |key, title|
|
|
553
|
+
entries = model.groups.fetch(key)
|
|
554
|
+
out.puts "#{title} (#{entries.length})"
|
|
555
|
+
entries.each { |change| out.puts " #{change.code} #{change.path}" }
|
|
556
|
+
end
|
|
557
|
+
if model.selected_path
|
|
558
|
+
out.puts "\nDiff: #{model.selected_path}"
|
|
559
|
+
model.diff.lines.first(500).each { |kind, text| out.write(kind == :insert ? "+" : kind == :delete ? "-" : " "); out.write(text) }
|
|
560
|
+
end
|
|
561
|
+
out.puts "\n#{model.message}" if model.respond_to?(:message) && model.message
|
|
562
|
+
out.puts "\n[space] stage [a] all [s] hunk [c] commit [X] discard [/] filter [r] reload [q] quit"
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def diff_runs(model, kind, text, theme)
|
|
566
|
+
prefix = kind == :insert ? "+" : kind == :delete ? "-" : " "
|
|
567
|
+
plain = "#{prefix}#{text}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
568
|
+
return [{text: plain, color: diff_color(kind, theme)}] unless model.config.dig("diff", "highlight")
|
|
569
|
+
|
|
570
|
+
tokens = model.diff.highlight(lines: [text], limit: 1).first || [[nil, text]]
|
|
571
|
+
[{text: prefix, color: diff_color(kind, theme)}] + tokens.map do |token, value|
|
|
572
|
+
{text: value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace), color: token_color(token, kind, theme)}
|
|
573
|
+
end
|
|
574
|
+
rescue StandardError
|
|
575
|
+
[{text: "#{prefix}#{text}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace), color: diff_color(kind, theme)}]
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def diff_color(kind, theme)
|
|
579
|
+
case kind
|
|
580
|
+
when :insert then theme.colors.success
|
|
581
|
+
when :delete then theme.colors.danger
|
|
582
|
+
else theme.colors.text
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def token_color(token, kind, theme)
|
|
587
|
+
return diff_color(kind, theme) unless token.respond_to?(:shortname)
|
|
588
|
+
case token.shortname.to_s
|
|
589
|
+
when /k|c/ then theme.colors.text_muted
|
|
590
|
+
when /nb|nc|nf|n/ then theme.colors.accent
|
|
591
|
+
when /s|dl/ then theme.colors.success
|
|
592
|
+
when /m|mi|mf/ then theme.colors.warning
|
|
593
|
+
else theme.colors.text
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
module GUI
|
|
599
|
+
module_function
|
|
600
|
+
|
|
601
|
+
def run(model, output: $stdout)
|
|
602
|
+
raise Error, "GUI backend unavailable" unless defined?(Zaniah::Platform)
|
|
603
|
+
backend = RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mswin|mingw/) ? :windows : :linux
|
|
604
|
+
app = Zaniah::App.new
|
|
605
|
+
window = app.open_window(backend: backend, width: 1200, height: 800, title: "Izar")
|
|
606
|
+
app.global(:theme, Theme.resolve(model.config.fetch("theme", "dark")))
|
|
607
|
+
session = TUI::Session.new(model, async: true)
|
|
608
|
+
window.draw { View.element(model) }
|
|
609
|
+
window.on_tick do
|
|
610
|
+
window.request_frame if model.busy?
|
|
611
|
+
window.request_frame if model.poll_operation
|
|
612
|
+
end
|
|
613
|
+
window.on_input do |event|
|
|
614
|
+
next unless event.is_a?(Zaniah::Input::KeyDown)
|
|
615
|
+
key = event.keystroke
|
|
616
|
+
next if %w[c X].include?(key)
|
|
617
|
+
result = session.dispatch(key)
|
|
618
|
+
window.request_frame unless result == :quit
|
|
619
|
+
window.close if result == :quit
|
|
620
|
+
end
|
|
621
|
+
window.run
|
|
622
|
+
rescue StandardError => error
|
|
623
|
+
output.puts "izar: GUI backend unavailable; using TUI (#{error.message})"
|
|
624
|
+
TUI.run(model, input: $stdin, output: output)
|
|
625
|
+
ensure
|
|
626
|
+
window&.close
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
class CLI
|
|
631
|
+
def self.run(argv, out: $stdout, err: $stderr, input: $stdin)
|
|
632
|
+
options = {dir: Dir.pwd, filter: nil, action: :status, yes: false, tui: false, gui: false}
|
|
633
|
+
OptionParser.new do |opts|
|
|
634
|
+
opts.banner = "Usage: izar [status|stage|unstage|discard|commit] [PATH]"
|
|
635
|
+
opts.on("-C PATH") { |v| options[:dir] = v }
|
|
636
|
+
opts.on("--filter TEXT") { |v| options[:filter] = v }
|
|
637
|
+
opts.on("--yes") { options[:yes] = true }
|
|
638
|
+
opts.on("--tui") { options[:tui] = true }
|
|
639
|
+
opts.on("--gui") { options[:gui] = true }
|
|
640
|
+
opts.on("-m MESSAGE") { |v| options[:message] = v }
|
|
641
|
+
end.parse!(argv)
|
|
642
|
+
options[:action] = argv.shift&.to_sym || :status
|
|
643
|
+
repository = Repository.new(options[:dir])
|
|
644
|
+
case options[:action]
|
|
645
|
+
when :status
|
|
646
|
+
model = Model.new(options[:dir]).filter(options[:filter]) if options[:filter]
|
|
647
|
+
model ||= Model.new(options[:dir])
|
|
648
|
+
if options[:gui] && input.tty? && out.tty?
|
|
649
|
+
GUI.run(model, output: out)
|
|
650
|
+
else
|
|
651
|
+
out.puts "izar: GUI backend unavailable; using TUI" if options[:gui]
|
|
652
|
+
options[:tui] ? TUI.run(model, input: input, output: out) : View.render(model, out: out)
|
|
653
|
+
end
|
|
654
|
+
when :stage then repository.stage(argv.fetch(0)); out.puts "staged #{argv.fetch(0)}"
|
|
655
|
+
when :unstage then repository.unstage(argv.fetch(0)); out.puts "unstaged #{argv.fetch(0)}"
|
|
656
|
+
when :discard then out.puts repository.discard(argv.fetch(0), confirm: options[:yes]).fetch(:message)
|
|
657
|
+
when :commit then out.puts "committed #{repository.commit(options[:message] || argv.fetch(0))}"
|
|
658
|
+
else raise Error, "unknown command: #{options[:action]}"
|
|
659
|
+
end
|
|
660
|
+
0
|
|
661
|
+
rescue OptionParser::ParseError, KeyError, Error => error
|
|
662
|
+
err.puts "izar: #{error.message}"
|
|
663
|
+
1
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
end
|
data/sig/izar.rbs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Izar
|
|
2
|
+
VERSION: String
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
end
|
|
5
|
+
class Repository
|
|
6
|
+
def initialize: (?String dir) -> void
|
|
7
|
+
def status: () -> Array[untyped]
|
|
8
|
+
def stage: (String path) -> String
|
|
9
|
+
def unstage: (String path) -> String
|
|
10
|
+
def commit: (String message) -> String
|
|
11
|
+
end
|
|
12
|
+
class Diff
|
|
13
|
+
def staged_to_worktree: () -> Array[untyped]
|
|
14
|
+
def head_to_staged: () -> Array[untyped]
|
|
15
|
+
end
|
|
16
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: izar
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: antares
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: fiddle
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: kochab
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.2'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.2'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: porrima
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.2'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.2'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: spica
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.1'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.1'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: thuban
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.6'
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.6'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: zaniah
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: 0.6.0
|
|
103
|
+
- - "<"
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0.7'
|
|
106
|
+
type: :runtime
|
|
107
|
+
prerelease: false
|
|
108
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: 0.6.0
|
|
113
|
+
- - "<"
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '0.7'
|
|
116
|
+
description: A safe, thin TUI and CLI layer over Thuban and Porrima for reviewing
|
|
117
|
+
and staging Git changes.
|
|
118
|
+
email:
|
|
119
|
+
- t.yudai92@gmail.com
|
|
120
|
+
executables:
|
|
121
|
+
- izar
|
|
122
|
+
extensions: []
|
|
123
|
+
extra_rdoc_files: []
|
|
124
|
+
files:
|
|
125
|
+
- CHANGELOG.md
|
|
126
|
+
- LICENSE.txt
|
|
127
|
+
- README.md
|
|
128
|
+
- Rakefile
|
|
129
|
+
- exe/izar
|
|
130
|
+
- lib/izar.rb
|
|
131
|
+
- lib/izar/version.rb
|
|
132
|
+
- sig/izar.rbs
|
|
133
|
+
homepage: https://github.com/noxdea/izar
|
|
134
|
+
licenses:
|
|
135
|
+
- MIT
|
|
136
|
+
metadata:
|
|
137
|
+
allowed_push_host: https://rubygems.org
|
|
138
|
+
source_code_uri: https://github.com/noxdea/izar
|
|
139
|
+
rubygems_mfa_required: 'true'
|
|
140
|
+
rdoc_options: []
|
|
141
|
+
require_paths:
|
|
142
|
+
- lib
|
|
143
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
144
|
+
requirements:
|
|
145
|
+
- - ">="
|
|
146
|
+
- !ruby/object:Gem::Version
|
|
147
|
+
version: 3.2.0
|
|
148
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
149
|
+
requirements:
|
|
150
|
+
- - ">="
|
|
151
|
+
- !ruby/object:Gem::Version
|
|
152
|
+
version: '0'
|
|
153
|
+
requirements: []
|
|
154
|
+
rubygems_version: 4.0.16
|
|
155
|
+
specification_version: 4
|
|
156
|
+
summary: Pure Ruby Git staging workspace
|
|
157
|
+
test_files: []
|