git-improved 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,1626 @@
1
+ #!/usr/bin/env ruby
2
+ # -*- coding: utf-8 -*-
3
+ # frozen_string_literal: true
4
+
5
+ ###
6
+ ### $Release: 0.1.0 $
7
+ ### $Copyright: copyright(c) 2023 kwatch@gmail.com $
8
+ ### $License: MIT License $
9
+ ###
10
+
11
+ require 'benry/cmdapp'
12
+ #require 'benry/unixcommand' # lazy load
13
+
14
+
15
+ if (RUBY_VERSION.split('.').collect(&:to_i) <=> [2, 6]) < 0
16
+ Kernel.module_eval do
17
+ alias __orig_system system
18
+ def system(*args, **kws)
19
+ if kws.delete(:exception)
20
+ __orig_system(*args, **kws) or
21
+ raise "Command failed: #{args.join(' ')}"
22
+ else
23
+ __orig_system(*args, **kws)
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+
30
+ module GitImproved
31
+
32
+ VERSION = "$Version: 0.0.0 $".split()[1]
33
+ ENVVAR_INITFILE = "GI_INITFILE"
34
+
35
+
36
+ class GitConfig
37
+
38
+ def initialize()
39
+ @prompt = "[#{File.basename($0)}]$ "
40
+ @default_action = "status:here" # or: "status:info"
41
+ @initial_branch = "main" # != 'master'
42
+ @initial_commit_message = "Initial commit (empty)"
43
+ @gitignore_items = ["*~", "*.DS_Store", "tmp", "*.pyc"]
44
+ @history_graph_format = "%C(auto)%h %ad <%al> | %d %s"
45
+ #@history_graph_format = "\e[32m%h %ad\e[0m <%al> \e[2m|\e[0m\e[33m%d\e[0m %s"
46
+ @history_graph_options = ["--graph", "--date=short", "--decorate"]
47
+ end
48
+
49
+ attr_accessor :prompt
50
+ attr_accessor :default_action
51
+ attr_accessor :initial_branch
52
+ attr_accessor :initial_commit_message
53
+ attr_accessor :gitignore_items
54
+ attr_accessor :history_graph_format
55
+ #attr_accessor :history_graph_format
56
+ attr_accessor :history_graph_options
57
+
58
+ end
59
+
60
+
61
+ GIT_CONFIG = GitConfig.new
62
+
63
+ APP_CONFIG = Benry::CmdApp::Config.new("Git Improved", VERSION).tap do |c|
64
+ c.option_topic = true
65
+ c.option_quiet = true
66
+ c.option_color = true
67
+ c.option_dryrun = true
68
+ c.format_option = " %-19s : %s"
69
+ c.format_action = " %-19s : %s"
70
+ c.backtrace_ignore_rexp = /\/gi(?:t-improved\.rb)?:/
71
+ c.help_postamble = {
72
+ "Example:" => <<END,
73
+ $ mkdir mysample # or: gi repo:clone github:<user>/<repo>
74
+ $ cd mysample
75
+ $ gi repo:init -u yourname -e yourname@gmail.com
76
+ $ vi README.md # create a new file
77
+ $ gi track README.md # track files into the repository
78
+ $ gi cc "add README file" # commit changes
79
+ $ vi README.md # update an existing file
80
+ $ gi stage . # add changes into staging area
81
+ $ gi staged # show changes in staging area
82
+ $ gi cc "update README file" # commit changes
83
+ $ gi repo:remote:origin github:yourname/mysample # set remote repo
84
+ $ gi push # upload local commits to remote repo
85
+ END
86
+ "Document:" => " https://kwatch.github.io/git-improved/",
87
+ }
88
+ end
89
+
90
+
91
+ class GitCommandFailed < Benry::CmdApp::CommandError
92
+
93
+ def initialize(git_command=nil)
94
+ super "Git command failed: #{git_command}"
95
+ @git_command = git_command
96
+ end
97
+
98
+ attr_reader :git_commit
99
+
100
+ end
101
+
102
+
103
+ module ActionHelper
104
+
105
+ def git(*args)
106
+ argstr = args.collect {|s| _qq(s) }.join(" ")
107
+ echoback("git #{argstr}")
108
+ return if $DRYRUN_MODE
109
+ out = $SUBPROCESS_OUTPUT || nil
110
+ if out
111
+ system(["git", "git"], *args, out: out, err: out) or
112
+ raise GitCommandFailed, "git #{argstr}"
113
+ else
114
+ system(["git", "git"], *args) or
115
+ raise GitCommandFailed, "git #{argstr}"
116
+ end
117
+ end
118
+
119
+ def git!(*args)
120
+ git(*args)
121
+ rescue GitCommandFailed
122
+ false
123
+ end
124
+
125
+ def system!(command)
126
+ out = $SUBPROCESS_OUTPUT || nil
127
+ if out
128
+ system command, exception: true, out: out, err: out
129
+ else
130
+ system command, exception: true
131
+ end
132
+ end
133
+
134
+ protected
135
+
136
+ def curr_branch()
137
+ return `git rev-parse --abbrev-ref HEAD`.strip()
138
+ end
139
+
140
+ def prev_branch()
141
+ #s = `git rev-parse --symbolic-full-name @{-1}`.strip()
142
+ #return s.split("/").last
143
+ return `git rev-parse --abbrev-ref @{-1}`.strip()
144
+ end
145
+
146
+ def parent_branch()
147
+ # ref: https://stackoverflow.com/questions/3161204/
148
+ # git show-branch -a \
149
+ # | sed 's/].*//' \
150
+ # | grep '\*' \
151
+ # | grep -v "\\[$(git branch --show-current)\$" \
152
+ # | head -n1 \
153
+ # | sed 's/^.*\[//'
154
+ curr = curr_branch()
155
+ end_str = "[#{curr}\n"
156
+ output = `git show-branch -a`
157
+ output.each_line do |line|
158
+ line = line.sub(/\].*/, '')
159
+ next unless line =~ /\*/
160
+ next if line.end_with?(end_str)
161
+ parent = line.sub(/^.*?\[/, '').strip()
162
+ return parent
163
+ end
164
+ return nil
165
+ end
166
+
167
+ def resolve_branch(branch)
168
+ case branch
169
+ when "CURR" ; return curr_branch()
170
+ when "PREV" ; return prev_branch()
171
+ when "PARENT" ; return parent_branch()
172
+ when "-" ; return prev_branch()
173
+ else ; return branch
174
+ end
175
+ end
176
+
177
+ def resolve_except_prev_branch(branch)
178
+ if branch == nil || branch == "-" || branch == "PREV"
179
+ return "-"
180
+ else
181
+ return resolve_branch(branch)
182
+ end
183
+ end
184
+
185
+ def resolve_repository_url(url)
186
+ case url
187
+ when /^github:/
188
+ url =~ /^github:(?:\/\/)?([^\/]+)\/([^\/]+)$/ or
189
+ raise action_error("Invalid GitHub URL: #{url}")
190
+ user = $1; project = $2
191
+ return "git@github.com:#{user}/#{project}.git"
192
+ when /^gitlab:/
193
+ url =~ /^gitlab:(?:\/\/)?([^\/]+)\/([^\/]+)$/ or
194
+ raise action_error("Invalid GitLub URL: #{url}")
195
+ user = $1; project = $2
196
+ return "git@gitlab.com:#{user}/#{project}.git"
197
+ else
198
+ return url
199
+ end
200
+ end
201
+
202
+ def remote_repo_of_branch(branch)
203
+ branch_ = Regexp.escape(branch)
204
+ output = `git config --get-regexp '^branch\\.#{branch_}\\.remote'`
205
+ arr = output.each_line.grep(/^branch\..*?\.remote (.*)/) { $1 }
206
+ remote = arr.empty? ? nil : arr[0]
207
+ return remote
208
+ end
209
+
210
+ def color_mode?
211
+ return $stdout.tty?
212
+ end
213
+
214
+ def ask_to_user(question)
215
+ print "#{question} "
216
+ $stdout.flush()
217
+ answer = $stdin.readline().strip()
218
+ return answer.empty? ? nil : answer
219
+ end
220
+
221
+ def ask_to_user!(question)
222
+ answer = ""
223
+ while answer.empty?
224
+ print "#{question}: "
225
+ $stdout.flush()
226
+ answer = $stdin.read().strip()
227
+ end
228
+ return answer
229
+ end
230
+
231
+ def confirm(question, default_yes: true)
232
+ if default_yes
233
+ return _confirm(question, "[Y/n]", "Y") {|ans| ans !~ /\A[nN]/ }
234
+ else
235
+ return _confirm(question, "[y/N]", "N") {|ans| ans !~ /\A[yY]/ }
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ def _confirm(question, prompt, default_answer, &block)
242
+ print "#{question} #{prompt}: "
243
+ $stdout.flush()
244
+ answer = $stdin.readline().strip()
245
+ anser = default_answer if answer.empty?
246
+ return yield(answer)
247
+ end
248
+
249
+ def _qq(str, force: false)
250
+ if force || str =~ /\A[-+\w.,:=%\/^@]+\z/
251
+ return str
252
+ elsif str =~ /\A(-[-\w]+=)/
253
+ return $1 + _qq($')
254
+ else
255
+ #return '"' + str.gsub(/[$!`\\"]/) { "\\#{$&}" } + '"'
256
+ return '"' + str.gsub(/[$!`\\"]/, "\\\\\\&") + '"'
257
+ end
258
+ end
259
+
260
+ def _same_commit_id?(branch1, branch2)
261
+ arr = `git rev-parse #{branch1} #{branch2}`.split()
262
+ return arr[0] == arr[1]
263
+ end
264
+
265
+ end
266
+
267
+
268
+ class GitAction < Benry::CmdApp::Action
269
+ #include Benry::UnixCommand ## include lazily
270
+ include ActionHelper
271
+
272
+ protected
273
+
274
+ def prompt()
275
+ return "[gi]$ "
276
+ end
277
+
278
+ def echoback(command)
279
+ e1, e2 = color_mode?() ? ["\e[2m", "\e[0m"] : ["", ""]
280
+ puts "#{e1}#{prompt()}#{command}#{e2}" unless $QUIET_MODE
281
+ #puts "#{e1}#{super}#{e2}" unless $QUIET_MODE
282
+ end
283
+
284
+ def _lazyload_unixcommand()
285
+ require 'benry/unixcommand'
286
+ GitAction.class_eval {
287
+ include Benry::UnixCommand
288
+ remove_method :mkdir, :cd, :touch
289
+ }
290
+ end
291
+ private :_lazyload_unixcommand
292
+
293
+ def sys(*args)
294
+ if $DRYRUN_MODE
295
+ echoback args.join(' ')
296
+ else
297
+ _lazyload_unixcommand()
298
+ super
299
+ end
300
+ end
301
+
302
+ def sys!(*args)
303
+ if $DRYRUN_MODE
304
+ echoback args.join(' ')
305
+ else
306
+ _lazyload_unixcommand()
307
+ super
308
+ end
309
+ end
310
+
311
+ def mkdir(*args)
312
+ if $DRYRUN_MODE
313
+ echoback "mkdir #{args.join(' ')}"
314
+ else
315
+ _lazyload_unixcommand()
316
+ super
317
+ end
318
+ end
319
+
320
+ def cd(dir, &block)
321
+ if $DRYRUN_MODE
322
+ echoback "cd #{dir}"
323
+ if File.directory?(dir)
324
+ Dir.chdir dir, &block
325
+ else
326
+ yield if block_given?()
327
+ end
328
+ echoback "cd -" if block_given?()
329
+ else
330
+ _lazyload_unixcommand()
331
+ super
332
+ end
333
+ end
334
+
335
+ def touch(*args)
336
+ if $DRYRUN_MODE
337
+ echoback "touch #{args.join(' ')}"
338
+ else
339
+ _lazyload_unixcommand()
340
+ super
341
+ end
342
+ end
343
+
344
+ public
345
+
346
+
347
+ ##
348
+ ## status:
349
+ ##
350
+ category "status:" do
351
+
352
+ @action.("same as 'stats:compact .'", important: true)
353
+ def here()
354
+ git "status", "-sb", "."
355
+ end
356
+
357
+ @action.("show various infomation of current status")
358
+ def info(path=".")
359
+ #command = "git status -sb #{path} | awk '/^\\?\\? /{print $2}' | sed 's!/$!!' | xargs ls -dF --color"
360
+ command = "git status -sb #{path} | sed -n 's!/$!!;/^??/s/^?? //p' | xargs ls -dF --color"
361
+ #command = "git status -sb #{path} | perl -ne 'print if s!^\\?\\? (.*?)/?$!\\1!' | xargs ls -dF --color"
362
+ #command = "git status -sb #{path} | ruby -ne \"puts \\$1 if /^\\?\\? (.*?)\\/?$/\" | xargs ls -dF --color"
363
+ echoback command
364
+ system! command
365
+ git "status", "-sb", "-uno", path
366
+ #run_action "branch:echo", "CURR"
367
+ end
368
+
369
+ status_optset = optionset {
370
+ @option.(:trackedonly, "-U", "ignore untracked files")
371
+ }
372
+
373
+ @action.("show status in compact format")
374
+ @optionset.(status_optset)
375
+ def compact(*path, trackedonly: false)
376
+ opts = trackedonly ? ["-uno"] : []
377
+ git "status", "-sb", *opts, *path
378
+ end
379
+
380
+ @action.("show status in default format")
381
+ @optionset.(status_optset)
382
+ def default(*path, trackedonly: false)
383
+ opts = trackedonly ? ["-uno"] : []
384
+ git "status", *opts, *path
385
+ end
386
+
387
+ end
388
+
389
+ define_alias "status", "status:compact"
390
+
391
+
392
+ ##
393
+ ## branch:
394
+ ##
395
+ category "branch:" do
396
+
397
+ @action.("list branches")
398
+ @option.(:all , "-a, --all" , "list both local and remote branches (default)")
399
+ @option.(:remote, "-r, --remote", "list remote branches")
400
+ @option.(:local , "-l, --local" , "list local branches")
401
+ def list(all: false, remote: false, local: false)
402
+ opt = remote ? "-r" : local ? "-l" : "-a"
403
+ git "branch", opt
404
+ end
405
+
406
+ @action.("switch to previous or other branch", important: true)
407
+ def switch(branch=nil)
408
+ branch = resolve_except_prev_branch(branch)
409
+ git "checkout", branch
410
+ #git "switch", branch
411
+ end
412
+
413
+ @action.("create a new branch, not switch to it")
414
+ @option.(:on, "--on=<commit>", "commit-id on where the new branch will be created")
415
+ @option.(:switch, "-w, --switch", "switch to the new branch after created")
416
+ def create(branch, on: nil, switch: false)
417
+ args = on ? [on] : []
418
+ git "branch", branch, *args
419
+ git "checkout", branch if switch
420
+ end
421
+
422
+ @action.("create a new branch and switch to it", important: true)
423
+ @option.(:on, "--on=<commit>", "commit-id on where the new branch will be created")
424
+ def fork(branch, on: nil)
425
+ args = on ? [on] : []
426
+ git "checkout", "-b", branch, *args
427
+ end
428
+
429
+ mergeopts = optionset {
430
+ @option.(:delete , "-d, --delete", "delete the current branch after merged")
431
+ @option.(:fastforward, " --ff", "use fast-forward merge")
432
+ @option.(:reuse , "-M", "reuse commit message (not invoke text editor for it)")
433
+ }
434
+
435
+ @action.("merge current branch into previous or other branch", important: true)
436
+ @optionset.(mergeopts)
437
+ def join(branch=nil, delete: false, fastforward: false, reuse: false)
438
+ into_branch = resolve_branch(branch || "PREV")
439
+ __merge(curr_branch(), into_branch, true, fastforward, delete, reuse)
440
+ end
441
+
442
+ @action.("merge previous or other branch into current branch")
443
+ @optionset.(mergeopts)
444
+ def merge(branch=nil, delete: false, fastforward: false, reuse: false)
445
+ merge_branch = resolve_branch(branch || "PREV")
446
+ __merge(merge_branch, curr_branch(), false, fastforward, delete, reuse)
447
+
448
+ end
449
+
450
+ def __merge(merge_branch, into_branch, switch, fastforward, delete, reuse)
451
+ b = proc {|s| "'\e[1m#{s}\e[0m'" } # bold font
452
+ #msg = "Merge #{b.(merge_branch)} branch into #{b.(into_branch)}?"
453
+ msg = switch \
454
+ ? "Merge current branch #{b.(merge_branch)} into #{b.(into_branch)}." \
455
+ : "Merge #{b.(merge_branch)} branch into #{b.(into_branch)}."
456
+ if confirm(msg + " OK?")
457
+ _check_fastforward_merge_available(into_branch, merge_branch)
458
+ opts = fastforward ? ["--ff-only"] : ["--no-ff"]
459
+ opts << "--no-edit" if reuse
460
+ git "checkout", into_branch if switch
461
+ git "merge", *opts, (switch ? "-" : merge_branch)
462
+ git "branch", "-d", merge_branch if delete
463
+ else
464
+ puts "** Not joined." if switch
465
+ puts "** Not merged." unless switch
466
+ end
467
+ end
468
+ private :__merge
469
+
470
+ def _check_fastforward_merge_available(parent_branch, child_branch)
471
+ parent, child = parent_branch, child_branch
472
+ cmd = "git merge-base --is-ancestor #{parent} #{child}"
473
+ result_ok = system cmd
474
+ result_ok or
475
+ raise action_error("Cannot merge '#{child}' branch; rebase it onto '#{parent}' in advance.")
476
+ end
477
+ private :_check_fastforward_merge_available
478
+
479
+ @action.("create a new local branch from a remote branch")
480
+ @option.(:remote, "--remote=<remote>", "remote repository name (default: origin)")
481
+ def checkout(branch, remote: "origin")
482
+ local_branch = branch
483
+ remote_branch = "#{remote}/#{branch}"
484
+ git "checkout", "-b", local_branch, remote_branch
485
+ end
486
+
487
+ @action.("rename the current branch to other name")
488
+ @option.(:target, "-t <branch>", "target branch instead of current branch")
489
+ def rename(new_branch, target: nil)
490
+ old_branch = target || curr_branch()
491
+ git "branch", "-m", old_branch, new_branch
492
+ end
493
+
494
+ @action.("delete a branch")
495
+ @option.(:force, "-f, --force", "delete forcedly even if not merged")
496
+ @option.(:remote, "-r, --remote[=origin]", "delete a remote branch")
497
+ def delete(branch, force: false, remote: nil)
498
+ if branch == nil
499
+ branch = curr_branch()
500
+ yes = confirm "Are you sure to delete current branch '#{branch}'?", default_yes: false
501
+ return unless yes
502
+ git "checkout", "-" unless remote
503
+ else
504
+ branch = resolve_branch(branch)
505
+ end
506
+ if remote
507
+ remote = "origin" if remote == true
508
+ opts = force ? ["-f"] : []
509
+ #git "push", *opts, remote, ":#{branch}"
510
+ git "push", "--delete", *opts, remote, branch
511
+ else
512
+ opts = force ? ["-D"] : ["-d"]
513
+ git "branch", *opts, branch
514
+ end
515
+ end
516
+
517
+ @action.("change commit-id of current HEAD")
518
+ @option.(:restore, "--restore", "restore files after reset")
519
+ def reset(commit, restore: false)
520
+ opts = []
521
+ opts << "--hard" if restore
522
+ git "reset", *opts, commit
523
+ end
524
+
525
+ @action.("rebase (move) current branch on top of other branch")
526
+ @option.(:from, "--from=<commit-id>", "commit-id where current branch started")
527
+ def rebase(branch_onto, branch_upstream=nil, from: nil)
528
+ br_onto = resolve_branch(branch_onto)
529
+ if from
530
+ git "rebase", "--onto=#{br_onto}", from+"^"
531
+ elsif branch_upstream
532
+ git "rebase", "--onto=#{br_onto}", resolve_branch(branch_upstream)
533
+ else
534
+ git "rebase", br_onto
535
+ end
536
+ end
537
+
538
+ @action.("git pull && git stash && git rebase && git stash pop")
539
+ @option.(:rebase, "-b, --rebase", "rebase if prev branch updated")
540
+ def update(branch=nil, rebase: false)
541
+ if curr_branch() == GIT_CONFIG.initial_branch
542
+ git "pull"
543
+ return
544
+ end
545
+ #
546
+ branch ||= prev_branch()
547
+ remote = remote_repo_of_branch(branch) or
548
+ raise action_error("Previous branch '#{branch}' has no remote repo. (Hint: run `gi branch:upstream -t #{branch} origin`.)")
549
+ puts "[INFO] previous: #{branch}, remote: #{remote}" unless $QUIET_MODE
550
+ #
551
+ git "fetch"
552
+ file_changed = ! `git diff`.empty?
553
+ remote_updated = ! _same_commit_id?(branch, "#{remote}/#{branch}")
554
+ rebase_required = ! `git log --oneline HEAD..#{branch}`.empty?
555
+ if remote_updated || (rebase && rebase_required)
556
+ git "stash", "push", "-q" if file_changed
557
+ if remote_updated
558
+ git "checkout", "-q", branch
559
+ #git "reset", "--hard", "#{remote}/#{branch}"
560
+ git "pull"
561
+ git "checkout", "-q", "-"
562
+ end
563
+ git "rebase", branch if rebase
564
+ git "stash", "pop", "-q" if file_changed
565
+ end
566
+ end
567
+
568
+ @action.("print upstream repo name of current branch")
569
+ def upstream()
570
+ #git! "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
571
+ branch = curr_branch()
572
+ echoback "git config --get-regexp '^branch\\.#{branch}\\.remote' | awk '{print $2}'"
573
+ output = `git config --get-regexp '^branch\\.#{branch}\\.remote'`
574
+ output.each_line {|line| puts line.split()[1] }
575
+ end
576
+
577
+ @action.("show current branch name")
578
+ def current()
579
+ git "rev-parse", "--abbrev-ref", "HEAD"
580
+ #git "symbolic-ref", "--short", "HEAD"
581
+ #git "branch", "--show-current"
582
+ end
583
+
584
+ @action.("show previous branch name")
585
+ def previous()
586
+ #git "rev-parse", "--symbolic-full-name", "@{-1}"
587
+ git "rev-parse", "--abbrev-ref", "@{-1}"
588
+ end
589
+
590
+ @action.("show parent branch name (EXPERIMENTAL)")
591
+ def parent()
592
+ # ref: https://stackoverflow.com/questions/3161204/
593
+ command = <<~'END'
594
+ git show-branch -a \
595
+ | sed 's/].*//' \
596
+ | grep '\*' \
597
+ | grep -v "\\[$(git branch --show-current)\$" \
598
+ | head -n1 \
599
+ | sed 's/^.*\[//'
600
+ END
601
+ echoback(command.gsub(/\\\n/, '').strip())
602
+ puts parent_branch()
603
+ end
604
+
605
+ @action.("print CURR/PREV/PARENT branch name")
606
+ def echo(branch)
607
+ case branch
608
+ when "CURR" ; run_action "current"
609
+ when "PREV", "-" ; run_action "previous"
610
+ when "PARENT" ; run_action "parent" # (EXPERIMENTAL)
611
+ else ; git "rev-parse", "--abbrev-ref", branch
612
+ end
613
+ end
614
+
615
+ end
616
+
617
+ define_alias("branches", "branch:list")
618
+ define_alias("branch" , "branch:create")
619
+ define_alias("switch" , "branch:switch")
620
+ define_alias("sw" , "branch:switch")
621
+ define_alias("fork" , "branch:fork")
622
+ define_alias("join" , "branch:join")
623
+ define_alias("merge" , "branch:merge")
624
+ define_alias("update" , "branch:update")
625
+
626
+
627
+ ##
628
+ ## file:
629
+ ##
630
+ category "file:" do
631
+
632
+ @action.("list (un)tracked/ignored/missing files")
633
+ @option.(:filtertype, "-F <filtertype>", "one of:", detail: <<~END)
634
+ - tracked : only tracked files (default)
635
+ - untracked : only not-tracked files
636
+ - ignored : ignored files by '.gitignore'
637
+ - missing : tracked but missing files
638
+ END
639
+ @option.(:full, "--full", "show full list")
640
+ def list(path=".", filtertype: "tracked", full: false)
641
+ method_name = "__file__list__#{filtertype}"
642
+ respond_to?(method_name, true) or (
643
+ s = self.private_methods.grep(/^__file__list__(.*)/) { $1 }.join('/')
644
+ raise option_error("#{filtertype}: Uknown filter type (expected: #{s}})")
645
+ )
646
+ __send__(method_name, path, full)
647
+ end
648
+
649
+ private
650
+
651
+ def __file__list__tracked(path, full)
652
+ paths = path ? [path] : []
653
+ git "ls-files", *paths
654
+ end
655
+
656
+ def __file__list__untracked(path, full)
657
+ opt = full ? " -u" : nil
658
+ echoback "git status -s#{opt} #{path} | grep '^?? '"
659
+ output = `git status -s#{opt} #{path}`
660
+ puts output.each_line().grep(/^\?\? /)
661
+ end
662
+
663
+ def __file__list__ignored(path, full)
664
+ opt = full ? "--ignored=matching" : "--ignored"
665
+ echoback "git status -s #{opt} #{path} | grep '^!! '"
666
+ output = `git status -s #{opt} #{path}`
667
+ puts output.each_line().grep(/^!! /)
668
+ end
669
+
670
+ def __file__list__missing(path, full)
671
+ paths = path ? [path] : []
672
+ git "ls-files", "--deleted", *paths
673
+ end
674
+
675
+ public
676
+
677
+ ## TODO: should move to 'file:' category?
678
+ @action.("register files into the repository", important: true)
679
+ @option.(:force, "-f, --force", "allow to track ignored files")
680
+ @option.(:recursive, "-r, --recursive", "track files under directories")
681
+ #@option.(:allow_empty_dir, "-e, --allow-empty-dir", "create '.gitkeep' to track empty directory")
682
+ def track(file, *file2, force: false, recursive: false)
683
+ files = [file] + file2
684
+ files.each do |x|
685
+ output = `git ls-files -- #{x}`
686
+ output.empty? or
687
+ raise action_error("#{x}: Already tracked.")
688
+ end
689
+ files.each do |x|
690
+ if File.directory?(x)
691
+ recursive or
692
+ raise action_error("#{x}: File expected, but is a directory (specify `-r` or `--recursive` otpion to track files under the directory).")
693
+ end
694
+ end
695
+ opts = force ? ["-f"] : []
696
+ git "add", *opts, *files
697
+ end
698
+
699
+ @action.("show changes of files", important: true)
700
+ def changes(*path)
701
+ git "diff", *path
702
+ end
703
+
704
+ @action.("move files into a directory")
705
+ @option.(:to, "--to=<dir>", "target directory")
706
+ def move(file, *file2, to: nil)
707
+ dir = to
708
+ dir != nil or
709
+ raise option_error("Option `--to=<dir>` required.")
710
+ File.exist?(dir) or
711
+ raise option_error("--to=#{dir}: Directory not exist (create it first).")
712
+ File.directory?(dir) or
713
+ raise option_error("--to=#{dir}: Not a directory (to rename files, use 'file:rename' action instead).")
714
+ files = [file] + file2
715
+ git "mv", *files, dir
716
+ end
717
+
718
+ @action.("rename a file or directory to new name")
719
+ def rename(old_file, new_file)
720
+ ! File.exist?(new_file) or
721
+ raise action_failed("#{new_file}: Already exist.")
722
+ git "mv", old_file, new_file
723
+ end
724
+
725
+ @action.("delete files or directories")
726
+ @option.(:recursive, "-r, --recursive", "delete files recursively.")
727
+ def delete(file, *file2, recursive: false)
728
+ files = [file] + file2
729
+ opts = recursive ? ["-r"] : []
730
+ git "rm", *opts, *files
731
+ end
732
+
733
+ @action.("restore files (= clear changes)", important: true)
734
+ def restore(*path)
735
+ if path.empty?
736
+ git "reset", "--hard"
737
+ #git "checkout", "--", "." # path required
738
+ else
739
+ #git "reset", "--hard", "--", *path #=> fatal: Cannot do hard reset with paths.
740
+ git "checkout", "--", *path
741
+ end
742
+ end
743
+
744
+ @action.("print commit-id, author, and timestap of each line")
745
+ @option.(:range, "-L <N1,N2|:func>", "range (start,end) or function name")
746
+ def blame(path, *path2, range: nil)
747
+ paths = [path] + path2
748
+ opts = []
749
+ opts << "-L" << range if range
750
+ git "blame", *opts, *paths
751
+ end
752
+
753
+ @action.("find by pattern")
754
+ def egrep(pattern, commit=nil)
755
+ args = []
756
+ args << commit if commit
757
+ git "grep", "-E", pattern, *args
758
+ end
759
+
760
+ end
761
+
762
+ define_alias("files" , "file:list")
763
+ #define_alias("ls" , "file:list")
764
+ define_alias("track" , "file:track")
765
+ define_alias("register" , "file:track")
766
+ define_alias("changes" , "file:changes")
767
+ #define_alias("move" , "file:move")
768
+ #define_alias("rename" , "file:rename")
769
+ #define_alias("delete" , "file:delete")
770
+ #define_alias("restore" , "file:restore")
771
+
772
+
773
+ ##
774
+ ## staging:
775
+ ##
776
+ category "staging:" do
777
+
778
+ @action.("add changes of files into staging area", important: true)
779
+ @option.(:pick, "-p, --pick", "pick up changes interactively")
780
+ #@option.(:update, "-u, --update", "add all changes of tracked files")
781
+ def add(path, *path2, pick: false) # , update: false
782
+ paths = [path] + path2
783
+ paths.each do |x|
784
+ next if File.directory?(x)
785
+ output = `git ls-files #{x}`
786
+ ! output.strip.empty? or
787
+ raise action_error("#{x}: Not tracked yet (run 'track' action instead).")
788
+ end
789
+ #
790
+ opts = []
791
+ opts << "-p" if pick
792
+ opts << "-u" unless pick
793
+ git "add", *opts, *paths
794
+ end
795
+
796
+ @action.("show changes in staging area", important: true)
797
+ def show(*path)
798
+ git "diff", "--cached", *path
799
+ end
800
+
801
+ @action.("edit changes in staging area")
802
+ def edit(*path)
803
+ git "add", "--edit", *path
804
+ end
805
+
806
+ @action.("delete all changes in staging area", important: true)
807
+ def clear(*path)
808
+ args = path.empty? ? [] : ["--"] + path
809
+ git "reset", "HEAD", *args
810
+ end
811
+
812
+ end
813
+
814
+ define_alias("stage" , "staging:add")
815
+ define_alias("staged" , "staging:show")
816
+ define_alias("unstage" , "staging:clear")
817
+ define_alias("pick" , ["staging:add", "-p"])
818
+
819
+
820
+ ##
821
+ ## commit:
822
+ ##
823
+ category "commit:" do
824
+
825
+ @action.("create a new commit", important: true)
826
+ #@option.(:message, "-m, --message=<message>", "commit message")
827
+ @option.(:file, "-f, --file=<file>", "commit message file")
828
+ def create(message=nil, *path, file: nil)
829
+ opts = []
830
+ opts << "-m" << message if message && ! message.empty?
831
+ opts << "--file=#{file}" if file
832
+ args = path.empty? ? [] : ["--", *path]
833
+ git "commit", *opts, *args
834
+ end
835
+
836
+ @action.("correct the last commit", important: true)
837
+ @option.(:reuse, "-M", "reuse commit message (not invoke text editor for it)")
838
+ def correct(reuse: false)
839
+ opts = reuse ? ["--no-edit"] : []
840
+ git "commit", "--amend", *opts
841
+ end
842
+
843
+ @action.("correct the previous commit")
844
+ @option.(:histedit, "-e, --histedit", "start 'history:edit' action after fixup commit created")
845
+ def fixup(commit, histedit: nil)
846
+ git "commit", "--fixup=#{commit}"
847
+ if histedit
848
+ run_once "history:edit:start", "#{commit}^"
849
+ end
850
+ end
851
+
852
+ @action.("apply a commit to curr branch (known as 'cherry-pick')")
853
+ def apply(commit, *commit2)
854
+ commits = [commit] + commit2
855
+ git "cherry-pick", *commits
856
+ end
857
+
858
+ @action.("show commits in current branch", important: true)
859
+ @option.(:count, "-n <N>", "show latest N commits", type: Integer)
860
+ @option.(:file, "-f, --file=<path>", "show commits related to file")
861
+ def show(commit=nil, count: nil, file: nil)
862
+ if count && commit
863
+ git "show", "#{commit}~#{count}..#{commit}"
864
+ elsif count
865
+ git "show", "HEAD~#{count}..HEAD"
866
+ elsif commit
867
+ git "show", commit
868
+ elsif file
869
+ git "log", "-p", "--", file
870
+ else
871
+ git "log", "-p"
872
+ end
873
+ end
874
+
875
+ @action.("create a new commit which reverts the target commit")
876
+ @option.(:count, "-n <N>", "show latest N commits", type: Integer)
877
+ @option.(:mainline, "--mainline=<N>", "parent number (necessary to revert merge commit)")
878
+ @option.(:reuse, "-M", "reuse commit message (not invoke text editor for it)")
879
+ def revert(*commit, count: nil, mainline: nil, reuse: false)
880
+ commits = commit
881
+ opts = []
882
+ opts << "--no-edit" if reuse
883
+ opts << "-m" << mainline.to_s if mainline
884
+ if count
885
+ commits.length <= 1 or
886
+ raise action_error("Multiple commits are not allowed when '-n' option specified.")
887
+ commit = commits.empty? ? "HEAD" : commits[0]
888
+ git "revert", *opts, "#{commit}~#{count}..#{commit}"
889
+ elsif ! commits.empty?
890
+ git "revert", *opts, *commits
891
+ else
892
+ raise action_error("`<commit-id>` or `-n <N>` option required.")
893
+ end
894
+ end
895
+
896
+ @action.("cancel recent commits up to the target commit-id", important: true)
897
+ @option.(:count , "-n <N>" , "cancel recent N commits", type: Integer)
898
+ @option.(:restore, "--restore", "restore files after rollback")
899
+ def rollback(commit=nil, count: nil, restore: false)
900
+ opts = restore ? ["--hard"] : []
901
+ if commit && count
902
+ raise action_failed("Commit-id and `-n` option are exclusive.")
903
+ elsif commit
904
+ git "reset", *opts, commit
905
+ elsif count
906
+ git "reset", *opts, "HEAD~#{count}"
907
+ else
908
+ git "reset", *opts, "HEAD^"
909
+ end
910
+ end
911
+
912
+ end
913
+
914
+ define_alias("commit" , "commit:create")
915
+ define_alias("cc" , "commit:create")
916
+ define_alias("correct" , "commit:correct")
917
+ define_alias("fixup" , "commit:fixup")
918
+ define_alias("commits" , "commit:show")
919
+ #define_alias("rollback", "commit:rollback")
920
+
921
+
922
+ ##
923
+ ## history:
924
+ ##
925
+ category "history:", action: "show" do
926
+
927
+ @action.("show commit history in various format", important: true)
928
+ @option.(:all, "-a, --all" , "show history of all branches")
929
+ @option.(:format, "-F, --format=<format>", "default/oneline/fuller/graph")
930
+ @option.(:author, "-u, --author", "show author name before '@' of email address (only for 'graph' format)")
931
+ def show(*path, all: false, format: "default", author: false)
932
+ opts = []
933
+ HISTORY_SHOW_OPTIONS.key?(format) or
934
+ raise option_error("#{format}: Unknown format.")
935
+ val = HISTORY_SHOW_OPTIONS[format]
936
+ case val
937
+ when nil ;
938
+ when String ; opts << val
939
+ when Array ; opts.concat(val)
940
+ when Proc ; opts.concat([val.call(author: author)].flatten)
941
+ else
942
+ raise TypeError.new("HISTORY_SHOW_OPTIONS[#{format.inspect}]: Unexpected type value: #{val.inspect}")
943
+ end
944
+ opts = ["--all"] + opts if all
945
+ ## use 'git!' to ignore pipe error when pager process quitted
946
+ git! "log", *opts, *path
947
+ end
948
+
949
+ HISTORY_SHOW_OPTIONS = {
950
+ "default" => nil,
951
+ "compact" => "--oneline",
952
+ "oneline" => "--oneline",
953
+ "detailed" => "--format=fuller",
954
+ "fuller" => "--format=fuller",
955
+ "graph" => proc {|author: false, **_kws|
956
+ fmt = GIT_CONFIG.history_graph_format
957
+ fmt = fmt.sub(/ ?<?%a[eEnNlL]>? ?/, ' ') unless author
958
+ opts = ["--format=#{fmt}"] + GIT_CONFIG.history_graph_options
959
+ opts
960
+ },
961
+ }
962
+
963
+ @action.("show commits not uploaded yet")
964
+ def notuploaded()
965
+ git "cherry", "-v"
966
+ end
967
+
968
+ ## history:edit
969
+ category "edit:" do
970
+
971
+ @action.("start `git rebase -i` to edit commit history", important: true)
972
+ @option.(:count , "-n, --num=<N>", "edit last N commits")
973
+ #@option.(:stash, "-s, --stash", "store current changes into stash temporarily")
974
+ def start(commit=nil, count: nil)
975
+ if commit && count
976
+ raise action_error("Commit-id and `-n` option are exclusive.")
977
+ elsif commit
978
+ nil
979
+ arg = "#{commit}^"
980
+ elsif count
981
+ arg = "HEAD~#{count}"
982
+ else
983
+ raise action_error("Commit-id or `-n` option required.")
984
+ end
985
+ git "rebase", "-i", "--autosquash", arg
986
+ end
987
+
988
+ @action.("resume (= conitnue) suspended `git rebase -i`")
989
+ def resume()
990
+ git "rebase", "--continue"
991
+ end
992
+
993
+ @action.("skip current commit and resume")
994
+ def skip()
995
+ git "rebase", "--skip"
996
+ end
997
+
998
+ @action.("cancel (or abort) `git rebase -i`")
999
+ def cancel()
1000
+ git "rebase", "--abort"
1001
+ end
1002
+
1003
+ end
1004
+
1005
+ end
1006
+
1007
+ define_alias "hist" , ["history", "-F", "graph"]
1008
+ #define_alias "history" , "history:show"
1009
+ define_alias "histedit" , "history:edit:start"
1010
+ #define_alias "histedit:resume", "history:edit:resume"
1011
+ #define_alias "histedit:skip" , "history:edit:skip"
1012
+ #define_alias "histedit:cancel", "history:edit:cancel"
1013
+
1014
+
1015
+ ##
1016
+ ## repo:
1017
+ ##
1018
+ category "repo:" do
1019
+
1020
+ def _config_user_and_email(user, email)
1021
+ if user == nil && `git config --get user.name`.strip().empty?
1022
+ user = ask_to_user "User name:"
1023
+ end
1024
+ git "config", "user.name" , user if user
1025
+ if email == nil && `git config --get user.email`.strip().empty?
1026
+ email = ask_to_user "Email address:"
1027
+ end
1028
+ git "config", "user.email", email if email
1029
+ end
1030
+ private :_config_user_and_email
1031
+
1032
+ def _generate_gitignore_file(filename)
1033
+ items = GIT_CONFIG.gitignore_items
1034
+ sep = "> "
1035
+ items.each do |x|
1036
+ echoback "echo %-14s %s %s" % ["'#{x}'", sep, filename]
1037
+ sep = ">>"
1038
+ end
1039
+ content = (items + [""]).join("\n")
1040
+ File.write(filename, content, encoding: 'utf-8')
1041
+ end
1042
+ private :_generate_gitignore_file
1043
+
1044
+ initopts = optionset() {
1045
+ @option.(:initial_branch, "-b, --branch=<branch>", "branch name (default: '#{GIT_CONFIG.initial_branch}')")
1046
+ @option.(:user , "-u, --user=<user>", "user name")
1047
+ @option.(:email, "-e, --email=<email>", "email address")
1048
+ @option.(:initial_commit, "-x", "not create an empty initial commit", value: false)
1049
+ }
1050
+
1051
+ @action.("initialize git repository with empty initial commit", important: true)
1052
+ @optionset.(initopts)
1053
+ def init(user: nil, email: nil, initial_branch: nil, initial_commit: true)
1054
+ ! File.exist?(".git") or
1055
+ raise action_error("Directory '.git' already exists.")
1056
+ branch ||= GIT_CONFIG.initial_branch
1057
+ git "init", "--initial-branch=#{branch}"
1058
+ _config_user_and_email(user, email)
1059
+ if initial_commit
1060
+ git "commit", "--allow-empty", "-m", GIT_CONFIG.initial_commit_message
1061
+ end
1062
+ filename = ".gitignore"
1063
+ _generate_gitignore_file(filename) unless File.exist?(filename)
1064
+ end
1065
+
1066
+ @action.("create a new directory and initialize it as a git repo")
1067
+ @optionset.(initopts)
1068
+ def create(name, user: nil, email: nil, initial_branch: nil, initial_commit: true)
1069
+ dir = name
1070
+ mkdir dir
1071
+ cd dir do
1072
+ run_once "init", user: user, email: email, initial_branch: initial_branch, initial_commit: initial_commit
1073
+ end
1074
+ end
1075
+
1076
+ @action.("copy a repository ('github:<user>/<repo>' is available)")
1077
+ @optionset.(initopts.select(:user, :email))
1078
+ def clone(url, dir=nil, user: nil, email: nil)
1079
+ url = resolve_repository_url(url)
1080
+ args = dir ? [dir] : []
1081
+ files = Dir.glob("*")
1082
+ git "clone", url, *args
1083
+ newdir = (Dir.glob("*") - files)[0] || dir
1084
+ cd newdir do
1085
+ _config_user_and_email(user, email)
1086
+ end if newdir
1087
+ end
1088
+
1089
+ ## repo:remote:
1090
+ category "remote:", action: "handle" do
1091
+
1092
+ @action.("list/get/set/delete remote repository", usage: [
1093
+ " # list",
1094
+ "<name> # get",
1095
+ "<name> <url> # set ('github:user/repo' is avaialble)",
1096
+ "<name> \"\" # delete",
1097
+ ], postamble: {
1098
+ "Example:" => <<~'END'.gsub(/^/, " "),
1099
+ $ gi repo:remote # list
1100
+ $ gi repo:remote origin # get
1101
+ $ gi repo:remote origin github:user1/repo1 # set
1102
+ $ gi repo:remote origin "" # delete
1103
+ END
1104
+ })
1105
+ def handle(name=nil, url=nil)
1106
+ url = resolve_repository_url(url) if url
1107
+ if name == nil
1108
+ git "remote", "-v"
1109
+ elsif url == nil
1110
+ git "remote", "get-url", name
1111
+ elsif url == ""
1112
+ git "remote", "remove", name
1113
+ elsif `git remote`.split().include?(name)
1114
+ git "remote", "set-url", name, url
1115
+ else
1116
+ git "remote", "add", name, url
1117
+ end
1118
+ end
1119
+
1120
+ @action.("get/set/delete origin (= default remote repository)", usage: [
1121
+ " # get",
1122
+ "<url> # set ('github:user/repo' is avaialble)",
1123
+ "\"\" # delete",
1124
+ ], postamble: {
1125
+ "Example:" => <<~'END'.gsub(/^/, " "),
1126
+ $ gi repo:remote:origin # get
1127
+ $ gi repo:remote:origin github:user1/repo1 # set
1128
+ $ gi repo:remote:origin "" # delete
1129
+ END
1130
+ })
1131
+ def origin(url=nil)
1132
+ run_action "repo:remote", "origin", url
1133
+ end
1134
+
1135
+ end
1136
+
1137
+ end
1138
+
1139
+
1140
+ ##
1141
+ ## tag:
1142
+ ##
1143
+ category "tag:", action: "handle" do
1144
+
1145
+ @action.("list/show/create/delete tags", important: true, usage: [
1146
+ " # list",
1147
+ "<tag> # show commit-id of the tag",
1148
+ "<tag> <commit> # create a tag on the commit",
1149
+ "<tag> HEAD # create a tag on current commit",
1150
+ "<tag> \"\" # delete a tag",
1151
+ ])
1152
+ @option.(:remote, "-r, --remote[=origin]", "list/delete tags on remote (not for show/create)")
1153
+ def handle(tag=nil, commit=nil, remote: nil)
1154
+ if tag == nil # list
1155
+ if remote
1156
+ #git "show-ref", "--tags"
1157
+ git "ls-remote", "--tags"
1158
+ else
1159
+ git "tag", "-l"
1160
+ end
1161
+ elsif commit == nil # show
1162
+ ! remote or
1163
+ raise option_error("Option '-r' or '--remote' is not available for showing tag.")
1164
+ git "rev-parse", tag
1165
+ elsif commit == "" # delete
1166
+ if remote
1167
+ remote = "origin" if remote == true
1168
+ #git "push", "--delete", remote, tag # may delete same name branch
1169
+ git "push", remote, ":refs/tags/#{tag}"
1170
+ else
1171
+ git "tag", "--delete", tag
1172
+ end
1173
+ else # create
1174
+ ! remote or
1175
+ raise option_error("Option '-r' or '--remote' is not available for creating tag.")
1176
+ git "tag", tag, commit
1177
+ end
1178
+ end
1179
+
1180
+ @action.("list tags")
1181
+ @option.(:remote, "-r, --remote", "list remote tags")
1182
+ def list(remote: false)
1183
+ if remote
1184
+ #git "show-ref", "--tags"
1185
+ git "ls-remote", "--tags"
1186
+ else
1187
+ git "tag", "-l"
1188
+ end
1189
+ end
1190
+
1191
+ @action.("create a new tag", important: true)
1192
+ #@option.(:on, "--on=<commit>", "commit-id where new tag created on")
1193
+ def create(tag, commit=nil)
1194
+ args = commit ? [commit] : []
1195
+ git "tag", tag, *args
1196
+ end
1197
+
1198
+ @action.("delete a tag")
1199
+ @option.(:remote, "-r, --remote[=origin]", "delete from remote repository")
1200
+ def delete(tag, *tag_, remote: nil)
1201
+ tags = [tag] + tag_
1202
+ if remote
1203
+ remote = "origin" if remote == true
1204
+ tags.each do |tag|
1205
+ #git "push", "--delete", remote, tag # may delete same name branch
1206
+ git "push", remote, ":refs/tags/#{tag}" # delete a tag safely
1207
+ end
1208
+ else
1209
+ git "tag", "-d", *tags
1210
+ end
1211
+ end
1212
+
1213
+ @action.("upload tags")
1214
+ def upload()
1215
+ git "push", "--tags"
1216
+ end
1217
+
1218
+ @action.("download tags")
1219
+ def download()
1220
+ git "fetch", "--tags", "--prune-tags"
1221
+ end
1222
+
1223
+ end
1224
+
1225
+ define_alias("tags", "tag:list")
1226
+
1227
+
1228
+ ##
1229
+ ## sync:
1230
+ ##
1231
+ category "sync:" do
1232
+
1233
+ uploadopts = optionset {
1234
+ @option.(:upstream, "-u <remote>" , "set upstream")
1235
+ @option.(:origin , "-U" , "same as '-u origin'")
1236
+ @option.(:force , "-f, --force" , "upload forcedly")
1237
+ }
1238
+
1239
+ @action.("download and upload commits")
1240
+ @optionset.(uploadopts)
1241
+ def both(upstream: nil, origin: false, force: false)
1242
+ run_action "pull"
1243
+ run_action "push", upstream: upstream, origin: origin, force: force
1244
+ end
1245
+
1246
+ @action.("upload commits to remote")
1247
+ @optionset.(uploadopts)
1248
+ def push(upstream: nil, origin: false, force: false)
1249
+ branch = curr_branch()
1250
+ upstream ||= "origin" if origin
1251
+ upstream ||= _ask_remote_repo(branch)
1252
+ #
1253
+ opts = []
1254
+ opts << "-f" if force
1255
+ if upstream
1256
+ git "push", *opts, "-u", upstream, branch # branch name is required
1257
+ else
1258
+ git "push", *opts
1259
+ end
1260
+ end
1261
+
1262
+ def _ask_remote_repo(branch)
1263
+ output = `git config --get-regexp '^branch\.'`
1264
+ has_upstream = output.each_line.any? {|line|
1265
+ line =~ /\Abranch\.(.*)\.remote / && $1 == branch
1266
+ }
1267
+ return nil if has_upstream
1268
+ remote = ask_to_user "Enter the remote repo name (default: \e[1morigin\e[0m) :"
1269
+ return remote && ! remote.empty? ? remote : "origin"
1270
+ end
1271
+ private :_ask_remote_repo
1272
+
1273
+ @action.("download commits from remote and apply them to local")
1274
+ @option.(:apply, "-N, --not-apply", "just download, not apply", value: false)
1275
+ def pull(apply: true)
1276
+ if apply
1277
+ git "pull", "--prune"
1278
+ else
1279
+ git "fetch", "--prune"
1280
+ end
1281
+ end
1282
+
1283
+ end
1284
+
1285
+ define_alias("sync" , "sync:both")
1286
+ define_alias("push" , "sync:push")
1287
+ define_alias("upload" , "sync:push")
1288
+ define_alias("up" , "sync:push")
1289
+ define_alias("pull" , "sync:pull")
1290
+ define_alias("download" , "sync:pull")
1291
+ define_alias("dl" , "sync:pull")
1292
+
1293
+
1294
+ ##
1295
+ ## stash:
1296
+ ##
1297
+ category "stash:" do
1298
+
1299
+ @action.("list stash history")
1300
+ def list()
1301
+ git "stash", "list"
1302
+ end
1303
+
1304
+ @action.("show changes on stash")
1305
+ @option.(:num, "-n <N>", "show N-th changes on stash (1-origin)", type: Integer)
1306
+ #@option.(:index, "-x, --index=<N>", "show N-th changes on stash (0-origin)", type: Integer)
1307
+ def show(num: nil)
1308
+ args = num ? ["stash@{#{num - 1}}"] : []
1309
+ git "stash", "show", "-p", *args
1310
+ end
1311
+
1312
+ @action.("save current changes into stash", important: true)
1313
+ @option.(:message, "-m <message>", "message")
1314
+ @option.(:pick, "-p, --pick" , "pick up changes interactively")
1315
+ def put(*path, message: nil, pick: false)
1316
+ opts = []
1317
+ opts << "-m" << message if message
1318
+ opts << "-p" if pick
1319
+ args = path.empty? ? [] : ["--"] + path
1320
+ git "stash", "push", *opts, *args
1321
+ end
1322
+
1323
+ @action.("restore latest changes from stash", important: true)
1324
+ @option.(:num, "-n <N>", "pop N-th changes on stash (1-origin)", type: Integer)
1325
+ def pop(num: nil)
1326
+ args = num ? ["stash@{#{num - 1}}"] : []
1327
+ git "stash", "pop", *args
1328
+ end
1329
+
1330
+ @action.("delete latest changes from stash")
1331
+ @option.(:num, "-n, --num=<N>", "drop N-th changes on stash (1-origin)", type: Integer)
1332
+ def drop(num: nil)
1333
+ args = num ? ["stash@{#{num - 1}}"] : []
1334
+ git "stash", "drop", *args
1335
+ end
1336
+
1337
+ end
1338
+
1339
+
1340
+ ##
1341
+ ## config:
1342
+ ##
1343
+ category "config:", action: "handle" do
1344
+
1345
+ optset = optionset() {
1346
+ @option.(:global, "-g, --global", "handle global config")
1347
+ @option.(:local , "-l, --local" , "handle repository local config")
1348
+ }
1349
+
1350
+ def _build_config_options(global, local)
1351
+ opts = []
1352
+ opts << "--global" if global
1353
+ opts << "--local" if local
1354
+ return opts
1355
+ end
1356
+
1357
+ @action.("list/get/set/delete config values", usage: [
1358
+ " # list",
1359
+ "<key> # get",
1360
+ "<key> <value> # set",
1361
+ "<key> \"\" # delete",
1362
+ "<prefix> # filter by prefix",
1363
+ ], postamble: {
1364
+ "Example:" => (<<~END).gsub(/^/, " "),
1365
+ $ gi config # list
1366
+ $ gi config core.editor # get
1367
+ $ gi config core.editor vim # set
1368
+ $ gi config core.editor "" # delete
1369
+ $ gi config core. # filter by prefix
1370
+ $ gi config . # list top level prefixes
1371
+ END
1372
+ })
1373
+ @optionset.(optset)
1374
+ def handle(key=nil, value=nil, global: false, local: false)
1375
+ opts = _build_config_options(global, local)
1376
+ if key == nil # list
1377
+ git "config", *opts, "--list"
1378
+ elsif value == nil # get or filter
1379
+ case key
1380
+ when "." # list top level prefixes
1381
+ echoback "gi config | awk -F. 'NR>1{d[$1]++}END{for(k in d){print(k\"\\t(\"d[k]\")\")}}' | sort"
1382
+ d = {}
1383
+ `git config -l #{opts.join(' ')}`.each_line do |line|
1384
+ d[$1] = (d[$1] || 0) + 1 if line =~ /^(\w+\.)/
1385
+ end
1386
+ d.keys.sort.each {|k| puts "#{k}\t(#{d[k]})" }
1387
+ when /\.$/ # list (filter)
1388
+ pat = "^"+key.gsub('.', '\\.')
1389
+ #git "config", *opts, "--get-regexp", pat # different with `config -l`
1390
+ echoback "git config -l #{opts.join(' ')} | grep '#{pat}'"
1391
+ `git config -l #{opts.join(' ')}`.each_line do |line|
1392
+ print line if line.start_with?(key)
1393
+ end
1394
+ else # get
1395
+ #git "config", "--get", *opts, key
1396
+ git "config", *opts, key
1397
+ end
1398
+ elsif value == "" # delete
1399
+ git "config", *opts, "--unset", key
1400
+ else # set
1401
+ git "config", *opts, key, value
1402
+ end
1403
+ end
1404
+
1405
+ @action.("set user name and email", usage: [
1406
+ "<user> <u@email> # set user name and email",
1407
+ "<user@email> # set email (contains '@')",
1408
+ "<user> # set user (not contain '@')",
1409
+ ])
1410
+ @optionset.(optset)
1411
+ def setuser(user, email=nil, global: false, local: false)
1412
+ opts = _build_config_options(global, local)
1413
+ if email == nil && user =~ /@/
1414
+ email = user
1415
+ user = nil
1416
+ end
1417
+ user = nil if user == '-'
1418
+ email = nil if email == '-'
1419
+ git "config", *opts, "user.name" , user if user
1420
+ git "config", *opts, "user.email", email if email
1421
+ end
1422
+
1423
+ @action.("list/get/set/delete aliases of 'git' (not of 'gi')", usage: [
1424
+ " # list",
1425
+ "<name> # get",
1426
+ "<name> <value> # set",
1427
+ "<name> \"\" # delete",
1428
+ ])
1429
+ def alias(name=nil, value=nil)
1430
+ if value == "" # delete
1431
+ git "config", "--global", "--unset", "alias.#{name}"
1432
+ elsif value != nil # set
1433
+ git "config", "--global", "alias.#{name}", value
1434
+ elsif name != nil # get
1435
+ git "config", "--global", "alias.#{name}"
1436
+ else # list
1437
+ command = "git config --get-regexp '^alias\\.' | sed -e 's/^alias\\.//;s/ /\\t= /'"
1438
+ echoback(command)
1439
+ output = `git config --get-regexp '^alias.'`
1440
+ print output.gsub(/^alias\.(\S+) (.*)/) { "%s\t= %s" % [$1, $2] }
1441
+ end
1442
+ end
1443
+
1444
+
1445
+ end
1446
+
1447
+
1448
+ ##
1449
+ ## misc:
1450
+ ##
1451
+ category "misc:" do
1452
+
1453
+ @action.("generate a init file, or print to stdout if no args",
1454
+ usage: [
1455
+ "<filename> # generate a file",
1456
+ " # print to stdout",
1457
+ ])
1458
+ def initfile(filename=nil)
1459
+ str = File.read(__FILE__, encoding: "utf-8")
1460
+ code = str.split(/^__END__\n/, 2)[1]
1461
+ code = code.gsub(/%SCRIPT%/, APP_CONFIG.app_command)
1462
+ code = code.gsub(/%ENVVAR_INITFILE%/, ENVVAR_INITFILE)
1463
+ #
1464
+ if ! filename || filename == "-"
1465
+ print code
1466
+ elsif File.exist?(filename)
1467
+ raise action_error("#{filename}: File already exists (remove it before generating new file).")
1468
+ else
1469
+ File.write(filename, code, encoding: 'utf-8')
1470
+ puts "[OK] #{filename} generated." unless $QUIET_MODE
1471
+ end
1472
+ end
1473
+
1474
+ end
1475
+
1476
+
1477
+ end
1478
+
1479
+
1480
+ Benry::CmdApp.module_eval do
1481
+ define_abbrev("b:" , "branch:")
1482
+ define_abbrev("c:" , "commit:")
1483
+ define_abbrev("C:" , "config:")
1484
+ define_abbrev("g:" , "staging:")
1485
+ define_abbrev("f:" , "file:")
1486
+ define_abbrev("r:" , "repo:")
1487
+ define_abbrev("r:r:", "repo:remote:")
1488
+ define_abbrev("h:" , "history:")
1489
+ define_abbrev("h:e:", "history:edit:")
1490
+ define_abbrev("histedit:", "history:edit:")
1491
+ #define_abbrev("t:" , "tag:")
1492
+ #define_abbrev("s:" , "status:")
1493
+ #define_abbrev("y:" , "sync:")
1494
+ #define_abbrev("T:" , "stash:")
1495
+ end
1496
+
1497
+
1498
+ class AppHelpBuilder < Benry::CmdApp::ApplicationHelpBuilder
1499
+
1500
+ def build_help_message(*args, **kwargs)
1501
+ @_omit_actions_part = true
1502
+ return super
1503
+ ensure
1504
+ @_omit_actions_part = false
1505
+ end
1506
+
1507
+ def section_actions(*args, **kwargs)
1508
+ if @_omit_actions_part
1509
+ text =" (Too long to show. Run `#{@config.app_command} -l` to list all actions.)"
1510
+ return render_section(header(:HEADER_ACTIONS), text)
1511
+ else
1512
+ return super
1513
+ end
1514
+ end
1515
+
1516
+ end
1517
+
1518
+
1519
+ def self.main(argv=ARGV)
1520
+ errmsg = _load_init_file(ENV[ENVVAR_INITFILE])
1521
+ if errmsg
1522
+ $stderr.puts "\e[31m[ERROR]\e[0m #{errmsg}"
1523
+ return 1
1524
+ end
1525
+ #
1526
+ APP_CONFIG.default_action = GIT_CONFIG.default_action
1527
+ app_help_builder = AppHelpBuilder.new(APP_CONFIG)
1528
+ app = Benry::CmdApp::Application.new(APP_CONFIG, nil, app_help_builder)
1529
+ return app.main(argv)
1530
+ end
1531
+
1532
+ def self._load_init_file(filename)
1533
+ return nil if filename == nil || filename.empty?
1534
+ filename = File.expand_path(filename)
1535
+ File.exist?(filename) or
1536
+ return "#{filename}: Init file specified but not exist."
1537
+ require File.absolute_path(filename)
1538
+ return nil
1539
+ end
1540
+ private_class_method :_load_init_file
1541
+
1542
+
1543
+ end
1544
+
1545
+
1546
+ if __FILE__ == $0
1547
+ exit GitImproved.main()
1548
+ end
1549
+
1550
+
1551
+ __END__
1552
+ # coding: utf-8
1553
+ # frozen_string_literal: true
1554
+
1555
+ ##
1556
+ ## @(#) Init file for '%SCRIPT%' command.
1557
+ ##
1558
+ ## This file is loaded by '%SCRIPT%' command only if $%ENVVAR_INITFILE% is set,
1559
+ ## for example:
1560
+ ##
1561
+ ## $ gi hello
1562
+ ## [ERROR] hello: Action not found.
1563
+ ##
1564
+ ## $ export %ENVVAR_INITFILE%="~/.gi_init.rb"
1565
+ ## $ gi hello
1566
+ ## Hello, world!
1567
+ ##
1568
+
1569
+ GitImproved.module_eval do
1570
+
1571
+
1572
+ ##
1573
+ ## Configuration example
1574
+ ##
1575
+ GIT_CONFIG.tap do |c|
1576
+ #c.prompt = "[gi]$ "
1577
+ #c.default_action = "status:here" # or: "status:info"
1578
+ #c.initial_branch = "main" # != 'master'
1579
+ #c.initial_commit_message = "Initial commit (empty)"
1580
+ #c.gitignore_items = ["*~", "*.DS_Store", "tmp/*", "*.pyc"]
1581
+ #c.history_graph_format = "%C(auto)%h %ad <%al> | %d %s"
1582
+ ##c.history_graph_format = "\e[32m%h %ad\e[0m <%al> \e[2m|\e[0m\e[33m%d\e[0m %s"
1583
+ #c.history_graph_options = ["--graph", "--date=short", "--decorate"]
1584
+ end
1585
+
1586
+
1587
+ ##
1588
+ ## Custom alias example
1589
+ ##
1590
+ GitAction.class_eval do
1591
+
1592
+ ## `gi br <branch>` == `gi breanch:create -w <branch>`
1593
+ define_alias "br", ["branch:create", "-w"]
1594
+
1595
+ end
1596
+
1597
+
1598
+ ##
1599
+ ## Custom action example
1600
+ ##
1601
+ GitAction.class_eval do
1602
+
1603
+ #category "example:" do
1604
+
1605
+ langs = ["en", "fr", "it"]
1606
+
1607
+ @action.("print greeting message")
1608
+ @option.(:lang, "-l, --lang=<lang>", "language (en/fr/it)", enum: langs)
1609
+ def hello(name="world", lang: "en")
1610
+ case lang
1611
+ when "en" ; puts "Hello, #{name}!"
1612
+ when "fr" ; puts "Bonjour, #{name}!"
1613
+ when "it" ; puts "Chao, #{name}!"
1614
+ else
1615
+ raise option_error("#{lang}: Unknown language.")
1616
+ end
1617
+ end
1618
+
1619
+ #end
1620
+
1621
+ #define_alias "hello", "example:hello"
1622
+
1623
+ end
1624
+
1625
+
1626
+ end