worktree 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.
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Worktree
6
+ module Config
7
+ def config_file
8
+ xdg_config_home = ENV.fetch('XDG_CONFIG_HOME') { "#{ENV['HOME']}/.config" }
9
+ _config_file = "#{xdg_config_home}/worktree/worktree.yml"
10
+ unless File.exist?(_config_file)
11
+ raise Worktree::Error, "config file #{_config_file} not found!"
12
+ end
13
+
14
+ _config_file
15
+ end
16
+
17
+ def config
18
+ YAML.load_file(Worktree::Config.config_file)
19
+ end
20
+
21
+ module_function :config_file, :config
22
+ end
23
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Worktree
6
+ class DbManager
7
+ attr_reader :spec
8
+
9
+ def initialize(config_file, environment = 'development')
10
+ @spec = YAML.load_file(config_file)
11
+ @environment = environment
12
+ end
13
+
14
+ def environment_spec
15
+ @spec.fetch(@environment, {})
16
+ end
17
+
18
+ def multi?
19
+ environment_spec.key? 'primary'
20
+ end
21
+
22
+ def db_port
23
+ if multi?
24
+ environment_spec.dig('primary', 'port')
25
+ else
26
+ environment_spec['port']
27
+ end
28
+ end
29
+
30
+ def template
31
+ if multi?
32
+ environment_spec.dig('primary', 'database')
33
+ else
34
+ environment_spec['database']
35
+ end
36
+ end
37
+
38
+ def createdb!(db_name)
39
+ cmd = if db_port
40
+ "createdb -h localhost -p #{db_port} -T #{template} #{db_name}"
41
+ else
42
+ "createdb -h localhost -T #{template} #{db_name}"
43
+ end
44
+ Worktree.run_command cmd
45
+ end
46
+
47
+ def dropdb!
48
+ cmd = if db_port
49
+ "dropdb -h localhost -p #{db_port} #{template}"
50
+ else
51
+ "dropdb -h localhost #{template}"
52
+ end
53
+ Worktree.run_command cmd
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Worktree
4
+ class Error < RuntimeError
5
+ end
6
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Worktree
6
+ module Feature
7
+ class CloneDbs
8
+
9
+ def initialize(project_dir:, branch:)
10
+ @project_dir = project_dir
11
+ @branch = branch
12
+ end
13
+
14
+ def run!
15
+ @db_manager = DbManager.new("#{@project_dir}/master/config/database.yml")
16
+ @db_manager.createdb!(db_name)
17
+
18
+ write!
19
+ rescue StandardError => e
20
+ # bypass error
21
+ Worktree.logger.error { e.message }
22
+ end
23
+
24
+ private
25
+
26
+ def write!
27
+ new_spec = @db_manager.spec.dup
28
+ if @db_manager.multi?
29
+ new_spec['development']['primary']['database'] = db_name
30
+ else
31
+ new_spec['development']['database'] = db_name
32
+ end
33
+ # write changed database config back
34
+ File.write("#{@project_dir}/#{@branch}/config/database.yml", new_spec.to_yaml)
35
+ end
36
+
37
+ def db_name
38
+ # db name cannot be > 63 bytes
39
+ db_suffix = '_development'
40
+ max_db_prefix_length = 62 - db_suffix.length
41
+ db_prefix = @branch[0..max_db_prefix_length]
42
+ "#{db_prefix}#{db_suffix}"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Worktree
6
+ module Feature
7
+ class CopyFiles
8
+
9
+ def initialize(project_dir:, branch:)
10
+ @project_dir = project_dir
11
+ @branch = branch
12
+ end
13
+
14
+ def run!
15
+ project_key = Worktree::Project.project_key_for(@branch)
16
+ paths = Worktree::Config.config.dig('projects', project_key, 'copy_files') || []
17
+ paths.each { |path| copy_file(path) }
18
+ end
19
+
20
+ private
21
+
22
+ def copy_file(file)
23
+ master_path = "#{@project_dir}/master/#{file}"
24
+ if File.exist?(master_path)
25
+ FileUtils.cp_r master_path, "#{@project_dir}/#{@branch}/#{file}"
26
+ else
27
+ print "The path #{master_path} was not found!"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jira-ruby'
4
+
5
+ module Worktree
6
+ module Feature
7
+ class Jira
8
+ def initialize(project_dir:, branch:)
9
+ @project_dir = project_dir
10
+ @branch = branch
11
+ end
12
+
13
+ def run!
14
+ if jira_issue?
15
+ prompt = "Jira issue #{jira_issue_id} status: #{jira_issue.status.name}. Would you like to change it?"
16
+ jira_process! unless TTY::Prompt.new.no?(prompt)
17
+ end
18
+
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ def jira_client
25
+ @jira_client ||= JIRA::Client.new(jira_client_options)
26
+ end
27
+
28
+ def jira_client_options
29
+ {
30
+ username: ENV['JIRA_USERNAME'],
31
+ password: ENV['JIRA_PASSWORD'],
32
+ site: ENV['JIRA_SITE'],
33
+ context_path: '',
34
+ auth_type: :basic
35
+ }
36
+ end
37
+
38
+ def jira_issue?
39
+ return false unless jira_issue_id
40
+
41
+ jira_issue_id =~ Worktree::JIRA_ISSUE_ID_REGEX
42
+ end
43
+
44
+ def jira_process!
45
+ transition = choose_transition
46
+ apply_transition!(transition) if transition != -1
47
+ rescue StandardError => e
48
+ Worktree.logger.error { e.message }
49
+ end
50
+
51
+ def jira_issue_id
52
+ (@branch.match(/^\w+\-\d+/) || [])[0]
53
+ end
54
+
55
+ def jira_issue
56
+ @jira_issue ||= jira_client.Issue.find(jira_issue_id)
57
+ end
58
+
59
+ def apply_transition!(transition)
60
+ jira_issue.transitions.build.save!(transition: { id: transition.id })
61
+ end
62
+
63
+ def choose_transition
64
+ TTY::Prompt.new.select('Choose a transition?', cycle: true) do |menu|
65
+ menu.enum '.'
66
+ menu.choice 'Skip it', -1
67
+ jira_issue.transitions.all.each do |s|
68
+ menu.choice s.name, s
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Worktree
4
+ module Feature
5
+ class Tmux
6
+
7
+ class VimEditor
8
+ attr_reader :window_name
9
+
10
+ def initialize(cwd:)
11
+ @cwd = cwd
12
+ @window_name = 'vim'
13
+ end
14
+
15
+ # open Gemfile if present
16
+ def cmd
17
+ if File.exist?("#{@cwd}/Gemfile")
18
+ 'vim Gemfile'
19
+ else
20
+ 'vim'
21
+ end
22
+ end
23
+ end
24
+
25
+ def initialize(project_dir:, branch:)
26
+ @project_dir = project_dir
27
+ @branch = branch
28
+ @working_directory = "#{@project_dir}/#{@branch}".chomp('/')
29
+ end
30
+
31
+ def run!(session_name)
32
+ if session_exist?(session_name)
33
+ Worktree.logger.info { "TMUX session #{session_name} already exist" }
34
+ # TODO: ask for attach to it
35
+ return
36
+ end
37
+
38
+ Worktree.run_command "tmux new-session -t #{session_name} -d", chdir: @working_directory
39
+ Worktree.run_command "tmux new-window -d -t #{session_name} -n #{editor.window_name}", chdir: @working_directory
40
+ Worktree.run_command "tmux send-keys -t #{session_name}:2 \"#{editor.cmd}\" C-m"
41
+ Worktree.run_command "tmux select-window -t #{session_name}:2" # select vim window
42
+ if inside_tmux?
43
+ Kernel.system "tmux switch -t #{session_name}"
44
+ else
45
+ Kernel.system "tmux attach-session -t #{session_name}"
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def editor
52
+ return @editor if defined?(@editor)
53
+
54
+ @editor = VimEditor.new(cwd: @working_directory)
55
+ end
56
+
57
+ def inside_tmux?
58
+ Worktree.run_command('echo $TMUX').out.strip.present?
59
+ end
60
+
61
+ def session_exist?(name)
62
+ cmd = "tmux list-sessions -F '#S' | awk '/'#{name}/' {print $1}'"
63
+ Worktree.run_command(cmd).out.strip.present?
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Worktree
4
+ class Project
5
+ def self.project_key_for(branch)
6
+ project_keys = Worktree::Config.config['projects'].keys
7
+ return nil if project_keys.empty?
8
+
9
+ re = Regexp.new("^(#{project_keys.join('|')})\-")
10
+ (branch.match(re) || [])[1]
11
+ end
12
+
13
+ def self.resolve(branch)
14
+ new(project_key_for(branch))
15
+ end
16
+
17
+ def initialize(key)
18
+ @key = key
19
+ end
20
+
21
+ def root
22
+ if @key
23
+ Worktree::Config.config.dig('projects', @key, 'root').chomp('/')
24
+ else
25
+ Dir.pwd
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'worktree'
4
+
5
+ module Worktree
6
+ module TabCompletion # :nodoc:
7
+ end
8
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jira-ruby'
4
+
5
+ module Worktree
6
+ module TabCompletion
7
+ class BranchCompletion
8
+ def initialize(compl)
9
+ @compl = compl
10
+ end
11
+
12
+ def list
13
+ issue_id = find_jira_issue_by(@compl)
14
+ if issue_id
15
+ jira_issue = jira_client.Issue.find(issue_id)
16
+ ["#{issue_id}-#{clean_jira_summary(jira_issue)}"]
17
+ else
18
+ []
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def find_jira_issue_by(comp_line)
25
+ (comp_line.match(Worktree::JIRA_ISSUE_ID_REGEX) || [])[0]
26
+ end
27
+
28
+ def jira_client
29
+ @jira_client ||= JIRA::Client.new(
30
+ username: ENV['JIRA_USERNAME'],
31
+ password: ENV['JIRA_PASSWORD'],
32
+ site: ENV['JIRA_SITE'],
33
+ context_path: '',
34
+ auth_type: :basic
35
+ )
36
+ end
37
+
38
+ def clean_jira_summary(jira_issue)
39
+ raw_summary = jira_issue.summary
40
+ raw_summary = raw_summary.strip
41
+
42
+ # translate raw summary to branch name
43
+ summary = raw_summary.split(' ').map(&:underscore).join('-')
44
+ summary.gsub!('&&', 'and')
45
+ summary.gsub!(/\(|\)/, '') # remove brackets
46
+ summary.gsub!(/"|'|”|“|«|»/, '') # remove quotes
47
+ summary.gsub!(/\.$/, '') # remove end period
48
+ summary.gsub!(%r{/}, '-') # change back slash to minus
49
+ summary.gsub!(/:|;/, '')
50
+ summary
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'worktree/tab_completion'
4
+
5
+ module Worktree
6
+ module TabCompletion
7
+ class CLI # :nodoc:
8
+ COMMAND_NEW = 'new'
9
+ COMMAND_CHERRY_PICK = 'cherry_pick'
10
+ COMMAND_REMOVE = 'remove'
11
+ COMMAND_CONFIGURE = 'configure'
12
+ COMMAND_OPEN = 'open'
13
+
14
+ def find_matches_for(command_line)
15
+ command_parameters = command_line.split(' ', -1)[1..-1]
16
+
17
+ # 'worktree '
18
+ # 'worktree n'
19
+ if command_parameters.empty? || command_parameters.size == 1
20
+ return [
21
+ COMMAND_NEW,
22
+ COMMAND_CHERRY_PICK,
23
+ COMMAND_REMOVE,
24
+ COMMAND_CONFIGURE,
25
+ COMMAND_OPEN
26
+ ]
27
+ end
28
+
29
+ # 'worktree new '
30
+ # 'worktree new DAPI'
31
+ if command_parameters.size == 2
32
+ command, compl = command_parameters
33
+
34
+ return BranchCompletion.new(compl).list if command == COMMAND_NEW
35
+ return WorktreeCompletion.new(compl).list if command == COMMAND_OPEN
36
+ return WorktreeCompletion.new(compl).list if command == COMMAND_REMOVE
37
+ # TODO: cherry_pick/remove command
38
+ end
39
+
40
+ # 'worktree new BRANCHNAME '
41
+ # 'worktree new BRANCHNAME --project-dir='
42
+ # 'worktree new BRANCHNAME --project-dir=../'
43
+ # 'worktree new BRANCHNAME --project-dir=/tmp/path'
44
+ # 'worktree new BRANCHNAME --project-dir=~/path'
45
+ # 'worktree new BRANCHNAME --from='
46
+ # 'worktree new BRANCHNAME --from=upstream/'
47
+ if command_parameters.size == 3
48
+ command, branch, compl = command_parameters
49
+
50
+ if command == COMMAND_NEW
51
+ if compl.starts_with?('--project-dir=')
52
+ return ProjectDirCompletion.new(compl[14..-1]).list.
53
+ map { |dir| "--project-dir=#{dir}" }
54
+ elsif command_parameters[2].starts_with?('--from=')
55
+ project_dir = Project.resolve(branch).root
56
+ return RemoteBranchCompletion.new(compl[7..-1], project_dir: project_dir).list.
57
+ map { |b| "--from=#{b}" }
58
+ else
59
+ return ['--from=', '--project-dir=']
60
+ end
61
+ end
62
+
63
+ if command == COMMAND_CHERRY_PICK
64
+ if compl.starts_with?('--project-dir=')
65
+ return ProjectDirCompletion.new(compl[14..-1]).list.
66
+ map { |dir| "--project-dir=#{dir}" }
67
+ elsif command_parameters[2].starts_with?('--to=')
68
+ return RemoteBranchCompletion.new(compl[5..-1]).list.
69
+ map { |dir| "--to=#{dir}" }
70
+ else
71
+ return ['--to=', '--project-dir=']
72
+ end
73
+ end
74
+
75
+ if command == COMMAND_REMOVE
76
+ if compl.starts_with?('--project-dir=')
77
+ return ProjectDirCompletion.new(compl[14..-1]).list.
78
+ map { |dir| "--project-dir=#{dir}" }
79
+ else
80
+ return ['--project-dir=']
81
+ end
82
+ end
83
+
84
+ if command == COMMAND_OPEN
85
+ if compl.starts_with?('--project-dir=')
86
+ return ProjectDirCompletion.new(compl[14..-1]).list.
87
+ map { |dir| "--project-dir=#{dir}" }
88
+ else
89
+ return ['--project-dir=']
90
+ end
91
+ end
92
+ end
93
+
94
+ if command_parameters.size == 4
95
+ command, _branch_name, project_dir_or_from, compl = command_parameters
96
+
97
+ if command == COMMAND_NEW
98
+ if project_dir_or_from.starts_with?('--project-dir=')
99
+ if compl.starts_with?('--from=')
100
+ project_dir = project_dir_or_from[14..-1]
101
+ return RemoteBranchCompletion.new(compl[7..-1], project_dir: project_dir).list.
102
+ map { |dir| "--from=#{dir}" }
103
+ else
104
+ return ['--from=']
105
+ end
106
+ elsif project_dir_or_from.starts_with?('--from=')
107
+ if compl.starts_with?('--project-dir=')
108
+ return ProjectDirCompletion.new(compl[14..-1]).list.
109
+ map { |dir| "--project-dir=#{dir}" }
110
+ else
111
+ return ['--project-dir=']
112
+ end
113
+ end
114
+ end
115
+
116
+ if command == COMMAND_CHERRY_PICK
117
+ if project_dir_or_from.starts_with?('--project-dir=')
118
+ if compl.starts_with?('--to=')
119
+ project_dir = project_dir_or_from[14..-1]
120
+ return RemoteBranchCompletion.new(compl[5..-1], project_dir: project_dir).list.
121
+ map { |dir| "--to=#{dir}" }
122
+ else
123
+ return ['--to=']
124
+ end
125
+ elsif project_dir_or_from.starts_with?('--to=')
126
+ if compl.starts_with?('--project-dir=')
127
+ return ProjectDirCompletion.new(compl[14..-1]).list.
128
+ map { |dir| "--project-dir=#{dir}" }
129
+ else
130
+ return ['--project-dir=']
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ []
137
+ end
138
+ end
139
+ end
140
+ end