git-jump 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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitJump
4
+ module Actions
5
+ # Base class for all actions
6
+ class Base
7
+ attr_reader :config, :database, :repository, :output
8
+
9
+ def initialize(config:, database:, repository:, output:)
10
+ @config = config
11
+ @database = database
12
+ @repository = repository
13
+ @output = output
14
+ end
15
+
16
+ def execute
17
+ raise NotImplementedError, "#{self.class} must implement #execute"
18
+ end
19
+
20
+ private
21
+
22
+ def project
23
+ @project ||= database.find_or_create_project(
24
+ repository.project_path,
25
+ repository.project_basename
26
+ )
27
+ end
28
+
29
+ def project_id
30
+ project["id"]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module GitJump
6
+ module Actions
7
+ # Action to clear branches matching keep patterns
8
+ class Clear < Base
9
+ def execute
10
+ branches = database.list_branches(project_id)
11
+
12
+ if branches.empty?
13
+ output.info("No branches tracked for #{repository.project_basename}")
14
+ return true
15
+ end
16
+
17
+ keep_patterns = config.keep_patterns(repository.project_path)
18
+
19
+ if keep_patterns.empty?
20
+ output.warning("No keep patterns configured. All branches would be deleted.")
21
+ output.info("Configure keep_patterns in your config file to use this command")
22
+ return false
23
+ end
24
+
25
+ output.info("Keep patterns: #{keep_patterns.join(", ")}")
26
+
27
+ unless output.prompt("Clear branches not matching patterns?")
28
+ output.info("Cancelled")
29
+ return false
30
+ end
31
+
32
+ deleted = database.clear_branches(project_id, keep_patterns)
33
+
34
+ if deleted.zero?
35
+ output.info("No branches to clear (all match keep patterns)")
36
+ else
37
+ output.success("Cleared #{deleted} branch(es)")
38
+ end
39
+
40
+ true
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module GitJump
6
+ module Actions
7
+ # Action to install post-checkout git hook
8
+ class Install < Base
9
+ HOOK_TEMPLATE = <<~BASH.freeze
10
+ #!/bin/sh
11
+ # Git Jump post-checkout hook
12
+ # Auto-generated - do not edit manually
13
+
14
+ PREV_HEAD=$1
15
+ NEW_HEAD=$2
16
+ BRANCH_CHECKOUT=$3
17
+
18
+ # Skip if called from git-jump itself to avoid double-loading
19
+ if [ -n "$GIT_JUMP_SKIP_HOOK" ]; then
20
+ exit 0
21
+ fi
22
+
23
+ # Only run on branch checkouts (not file checkouts)
24
+ if [ "$BRANCH_CHECKOUT" = "1" ]; then
25
+ RUBY_PATH="$(which ruby)"
26
+ #{" "}
27
+ if [ -z "$RUBY_PATH" ]; then
28
+ exit 0
29
+ fi
30
+ #{" "}
31
+ "$RUBY_PATH" -e "
32
+ begin
33
+ require 'git_jump'
34
+ GitJump::Hooks::PostCheckout.new('$(pwd)').run
35
+ rescue LoadError
36
+ # Gem not available, skip silently
37
+ rescue => e
38
+ # Silent error handling in hook
39
+ end
40
+ " 2>/dev/null
41
+ fi
42
+ BASH
43
+
44
+ def execute
45
+ if repository.hook_installed?("post-checkout")
46
+ existing_content = repository.read_hook("post-checkout")
47
+
48
+ if existing_content&.include?("Git Jump post-checkout hook")
49
+ output.info("Git Jump hook already installed")
50
+ return true
51
+ else
52
+ output.warning("A post-checkout hook already exists")
53
+ return false unless output.prompt("Overwrite existing hook?")
54
+ end
55
+ end
56
+
57
+ repository.install_hook("post-checkout", HOOK_TEMPLATE)
58
+ output.success("Installed post-checkout hook in #{repository.project_basename}")
59
+ output.info("Branches will now be automatically tracked on checkout")
60
+
61
+ true
62
+ rescue StandardError => e
63
+ output.error("Failed to install hook: #{e.message}")
64
+ false
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module GitJump
6
+ module Actions
7
+ # Action to jump to next branch or specific index
8
+ class Jump < Base
9
+ attr_reader :index
10
+
11
+ def initialize(index: nil, **)
12
+ super(**)
13
+ @index = index
14
+ end
15
+
16
+ def execute
17
+ branches = database.list_branches(project_id)
18
+
19
+ if branches.empty?
20
+ output.error("No branches tracked for #{repository.project_basename}")
21
+ output.info("Use 'git-jump add <branch>' to add branches")
22
+ return false
23
+ end
24
+
25
+ target_branch = if index
26
+ database.branch_at_index(project_id, index.to_i)
27
+ else
28
+ current_branch = repository.current_branch
29
+ database.next_branch(project_id, current_branch)
30
+ end
31
+
32
+ unless target_branch
33
+ output.error("Invalid branch index: #{index}") if index
34
+ return false
35
+ end
36
+
37
+ if target_branch == repository.current_branch
38
+ output.info("Already on branch '#{target_branch}'")
39
+ return true
40
+ end
41
+
42
+ repository.checkout(target_branch)
43
+ database.add_branch(project_id, target_branch) # Update last_visited_at
44
+
45
+ output.success("Switched to branch '#{target_branch}'")
46
+ true
47
+ rescue StandardError => e
48
+ output.error(e.message)
49
+ false
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module GitJump
6
+ module Actions
7
+ # Action to list tracked branches
8
+ class List < Base
9
+ def execute
10
+ branches = database.list_branches(project_id)
11
+
12
+ if branches.empty?
13
+ output.info("No branches tracked for #{repository.project_basename}")
14
+ output.info("Use 'git-jump add <branch>' to add branches or 'git-jump install' to setup automatic tracking")
15
+ return true
16
+ end
17
+
18
+ current_branch = repository.current_branch
19
+ output.branch_list(branches, current_branch)
20
+
21
+ true
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitJump
4
+ module Actions
5
+ # Action to initialize/setup configuration file
6
+ class Setup
7
+ attr_reader :config_path, :output
8
+
9
+ def initialize(output:, config_path: nil, **_options)
10
+ @config_path = config_path || Utils::XDG.config_path
11
+ @output = output
12
+ end
13
+
14
+ def execute
15
+ if File.exist?(@config_path)
16
+ output.warning("Config file already exists at: #{@config_path}")
17
+ return false unless output.prompt("Overwrite existing config?")
18
+ end
19
+
20
+ Utils::XDG.ensure_directories!
21
+
22
+ File.write(@config_path, Config.default_config_content)
23
+
24
+ output.success("Created config file at: #{@config_path}")
25
+ output.info("Edit this file to customize your branch tracking settings")
26
+
27
+ true
28
+ rescue StandardError => e
29
+ output.error("Failed to create config file: #{e.message}")
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module GitJump
6
+ module Actions
7
+ # Action to display current status and configuration
8
+ class Status < Base
9
+ def execute
10
+ output.heading("Git Jump Status")
11
+
12
+ output.info("Project: #{repository.project_basename}")
13
+ output.info("Path: #{repository.project_path}")
14
+ output.info("Current branch: #{repository.current_branch || "(none)"}")
15
+
16
+ output.heading("Configuration")
17
+ output.info("Config file: #{config.path}")
18
+ output.info("Config exists: #{config.exists? ? "Yes" : "No"}")
19
+ output.info("Database: #{config.database_path}")
20
+ output.info("Max branches: #{config.max_branches}")
21
+ output.info("Auto-track: #{config.auto_track? ? "Enabled" : "Disabled"}")
22
+ output.info("Keep patterns: #{config.keep_patterns.join(", ")}")
23
+
24
+ output.heading("Hook Status")
25
+ hook_installed = repository.hook_installed?("post-checkout")
26
+ output.info("Post-checkout hook: #{hook_installed ? "Installed" : "Not installed"}")
27
+
28
+ output.heading("Tracking Statistics")
29
+ stats = database.project_stats(project_id)
30
+ output.info("Total branches tracked: #{stats[:total_branches]}")
31
+
32
+ output.info("Most recent: #{stats[:most_recent]["name"]}") if stats[:most_recent]
33
+
34
+ true
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module GitJump
6
+ # Command-line interface using OptionParser
7
+ class CLI
8
+ def self.start(argv)
9
+ new.run(argv)
10
+ end
11
+
12
+ def run(argv)
13
+ @global_options = { config: nil, quiet: false, verbose: false }
14
+
15
+ if argv.empty?
16
+ print_help
17
+ exit(0)
18
+ end
19
+
20
+ command = argv.shift
21
+
22
+ case command
23
+ when "setup"
24
+ setup_command(argv)
25
+ when "install"
26
+ install_command(argv)
27
+ when "add"
28
+ add_command(argv)
29
+ when "list"
30
+ list_command(argv)
31
+ when "jump"
32
+ jump_command(argv)
33
+ when "clear"
34
+ clear_command(argv)
35
+ when "status"
36
+ status_command(argv)
37
+ when "version", "-v", "--version"
38
+ version_command
39
+ when "help", "-h", "--help"
40
+ print_help
41
+ else
42
+ warn "Unknown command: #{command}"
43
+ warn "Run 'git-jump help' for usage information."
44
+ exit(1)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def setup_command(argv)
51
+ parse_global_options(argv)
52
+ require_relative "loaders/setup_loader"
53
+ output = create_output
54
+ action = GitJump::Actions::Setup.new(
55
+ config_path: @global_options[:config],
56
+ output: output
57
+ )
58
+ exit(1) unless action.execute
59
+ end
60
+
61
+ def install_command(argv)
62
+ parse_global_options(argv)
63
+ require_relative "loaders/install_loader"
64
+ action = create_action(GitJump::Actions::Install)
65
+ exit(1) unless action.execute
66
+ rescue StandardError => e
67
+ handle_repository_error(e)
68
+ end
69
+
70
+ def add_command(argv)
71
+ options = { verify: true }
72
+
73
+ parser = OptionParser.new do |opts|
74
+ parse_global_option_definitions(opts)
75
+ opts.on("--[no-]verify", "Verify branch exists (default: true)") do |v|
76
+ options[:verify] = v
77
+ end
78
+ end
79
+
80
+ parser.parse!(argv)
81
+ parse_global_options_from_parsed(parser)
82
+
83
+ if argv.empty?
84
+ warn "Error: BRANCH argument is required"
85
+ warn "Usage: git-jump add BRANCH [options]"
86
+ exit(1)
87
+ end
88
+
89
+ branch_name = argv.shift
90
+
91
+ require_relative "loaders/add_loader"
92
+ action = create_action(GitJump::Actions::Add, branch_name: branch_name, verify: options[:verify])
93
+ exit(1) unless action.execute
94
+ rescue StandardError => e
95
+ handle_repository_error(e)
96
+ end
97
+
98
+ def list_command(argv)
99
+ parse_global_options(argv)
100
+ require_relative "loaders/list_loader"
101
+ action = create_action(GitJump::Actions::List)
102
+ exit(1) unless action.execute
103
+ rescue StandardError => e
104
+ handle_repository_error(e)
105
+ end
106
+
107
+ def jump_command(argv)
108
+ parse_global_options(argv)
109
+ index = argv.shift
110
+
111
+ require_relative "loaders/jump_loader"
112
+ action = create_action(GitJump::Actions::Jump, index: index)
113
+ exit(1) unless action.execute
114
+ rescue StandardError => e
115
+ handle_repository_error(e)
116
+ end
117
+
118
+ def clear_command(argv)
119
+ parse_global_options(argv)
120
+ require_relative "loaders/clear_loader"
121
+ action = create_action(GitJump::Actions::Clear)
122
+ exit(1) unless action.execute
123
+ rescue StandardError => e
124
+ handle_repository_error(e)
125
+ end
126
+
127
+ def status_command(argv)
128
+ parse_global_options(argv)
129
+ require_relative "loaders/status_loader"
130
+ action = create_action(GitJump::Actions::Status)
131
+ exit(1) unless action.execute
132
+ rescue StandardError => e
133
+ handle_repository_error(e)
134
+ end
135
+
136
+ def version_command
137
+ require_relative "version" unless defined?(GitJump::VERSION)
138
+ puts "git-jump #{GitJump::VERSION}"
139
+ end
140
+
141
+ def parse_global_options(argv)
142
+ parser = OptionParser.new do |opts|
143
+ parse_global_option_definitions(opts)
144
+ end
145
+ parser.parse!(argv)
146
+ parse_global_options_from_parsed(parser)
147
+ end
148
+
149
+ def parse_global_option_definitions(opts)
150
+ opts.on("-c", "--config PATH", "Path to config file") do |c|
151
+ @global_options[:config] = c
152
+ end
153
+ opts.on("-q", "--quiet", "Suppress output") do
154
+ @global_options[:quiet] = true
155
+ end
156
+ opts.on("-v", "--verbose", "Verbose output") do
157
+ @global_options[:verbose] = true
158
+ end
159
+ end
160
+
161
+ def parse_global_options_from_parsed(_parser)
162
+ # Already set in parse_global_option_definitions callbacks
163
+ end
164
+
165
+ def print_help
166
+ puts <<~HELP
167
+ Usage: git-jump COMMAND [options]
168
+
169
+ Smart git branch tracker and switcher with SQLite persistence
170
+
171
+ Commands:
172
+ setup Initialize configuration file
173
+ install Install post-checkout git hook in current repository
174
+ add BRANCH Manually add a branch to tracking
175
+ list List tracked branches for current project
176
+ jump [INDEX] Jump to next branch or specific index
177
+ clear Clear branches not matching keep patterns
178
+ status Show current status and configuration
179
+ version Show version
180
+ help Show this help message
181
+
182
+ Global Options:
183
+ -c, --config PATH Path to config file
184
+ -q, --quiet Suppress output
185
+ -v, --verbose Verbose output
186
+
187
+ Examples:
188
+ git-jump setup # Initialize configuration
189
+ git-jump install # Install git hook
190
+ git-jump add feature/new # Add branch to tracking
191
+ git-jump list # Show tracked branches
192
+ git-jump jump # Jump to next branch
193
+ git-jump jump 3 # Jump to branch at index 3
194
+ git-jump clear # Clear old branches
195
+
196
+ For more information, visit: https://github.com/dsaenztagarro/git-jump
197
+ HELP
198
+ end
199
+
200
+ def create_output
201
+ require_relative "utils/output" unless defined?(Utils::Output)
202
+ Utils::Output.new(
203
+ quiet: @global_options[:quiet],
204
+ verbose: @global_options[:verbose]
205
+ )
206
+ end
207
+
208
+ def create_action(action_class, **extra_options)
209
+ # Dependencies are already loaded by action-specific loaders
210
+ output = create_output
211
+ config = Config.new(@global_options[:config])
212
+ repository = Repository.new
213
+ database = Database.new(config.database_path)
214
+
215
+ action_class.new(
216
+ config: config,
217
+ database: database,
218
+ repository: repository,
219
+ output: output,
220
+ **extra_options
221
+ )
222
+ end
223
+
224
+ def handle_repository_error(error)
225
+ # Check if it's a NotAGitRepositoryError without requiring it to be loaded
226
+ raise error unless error.class.name.end_with?("NotAGitRepositoryError")
227
+
228
+ create_output.error(error.message)
229
+ exit(1)
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitJump
4
+ # Lightweight color module using ANSI escape codes
5
+ # Inspired by dotsync's approach - no external dependencies
6
+ module Colors
7
+ # ANSI color codes (256-color palette)
8
+ # Use \e[38;5;NNNm for foreground colors
9
+ # Use \e[1m for bold
10
+ # Use \e[0m to reset
11
+
12
+ GREEN = 34
13
+ RED = 196
14
+ YELLOW = 220
15
+ BLUE = 39
16
+ CYAN = 51
17
+ DIM = 242
18
+
19
+ module_function
20
+
21
+ def colorize(text, color:, bold: false)
22
+ codes = []
23
+ codes << "\e[38;5;#{color}m" if color
24
+ codes << "\e[1m" if bold
25
+ "#{codes.join}#{text}\e[0m"
26
+ end
27
+
28
+ def green(text, bold: false)
29
+ colorize(text, color: GREEN, bold: bold)
30
+ end
31
+
32
+ def red(text, bold: false)
33
+ colorize(text, color: RED, bold: bold)
34
+ end
35
+
36
+ def yellow(text, bold: false)
37
+ colorize(text, color: YELLOW, bold: bold)
38
+ end
39
+
40
+ def blue(text, bold: false)
41
+ colorize(text, color: BLUE, bold: bold)
42
+ end
43
+
44
+ def cyan(text, bold: false)
45
+ colorize(text, color: CYAN, bold: bold)
46
+ end
47
+
48
+ def dim(text)
49
+ colorize(text, color: DIM, bold: false)
50
+ end
51
+
52
+ def bold(text)
53
+ "\e[1m#{text}\e[0m"
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitJump
4
+ # Manages configuration from TOML file
5
+ class Config
6
+ attr_reader :path, :data
7
+
8
+ DEFAULT_CONFIG = {
9
+ "database" => {
10
+ "path" => "$XDG_DATA_HOME/git-jump/branches.db"
11
+ },
12
+ "tracking" => {
13
+ "max_branches" => 20,
14
+ "auto_track" => true,
15
+ "keep_patterns" => ["^main$", "^master$", "^develop$", "^staging$"]
16
+ },
17
+ "projects" => []
18
+ }.freeze
19
+
20
+ def initialize(path = nil)
21
+ @path = Utils::XDG.config_path(path)
22
+ @data = load_config
23
+ end
24
+
25
+ # Cache the config instance per path to avoid reloading
26
+ @instances = {}
27
+
28
+ def self.instance(path = nil)
29
+ normalized_path = Utils::XDG.config_path(path)
30
+ @instances[normalized_path] ||= new(path)
31
+ end
32
+
33
+ def self.default_config_content
34
+ <<~TOML
35
+ [database]
36
+ # SQLite database location (defaults to XDG_DATA_HOME/git-jump/branches.db)
37
+ # You can use environment variables like $XDG_DATA_HOME or $HOME
38
+ path = "$XDG_DATA_HOME/git-jump/branches.db"
39
+
40
+ [tracking]
41
+ # Maximum number of branches to track per project
42
+ max_branches = 20
43
+
44
+ # Automatically track branches on checkout (via git hook)
45
+ auto_track = true
46
+
47
+ # Global branch patterns to always keep when clearing (regex patterns)
48
+ keep_patterns = ["^main$", "^master$", "^develop$", "^staging$"]
49
+
50
+ # Example project-specific configuration
51
+ # [[projects]]
52
+ # name = "my-project"
53
+ # path = "/path/to/my-project"
54
+ # keep_patterns = ["^main$", "^feature/.*$"]
55
+ TOML
56
+ end
57
+
58
+ def exists?
59
+ File.exist?(@path)
60
+ end
61
+
62
+ def database_path
63
+ expand_env_vars(data.dig("database", "path") || Utils::XDG.database_path)
64
+ end
65
+
66
+ def max_branches
67
+ data.dig("tracking", "max_branches") || 20
68
+ end
69
+
70
+ def auto_track?
71
+ data.dig("tracking", "auto_track") != false
72
+ end
73
+
74
+ def keep_patterns(project_path = nil)
75
+ # Check for project-specific patterns first
76
+ if project_path
77
+ project = find_project(project_path)
78
+ return project["keep_patterns"] if project && project["keep_patterns"]
79
+ end
80
+
81
+ # Fall back to global patterns
82
+ data.dig("tracking", "keep_patterns") || []
83
+ end
84
+
85
+ def find_project(project_path)
86
+ projects = data["projects"] || []
87
+ projects.find { |p| p["path"] == project_path }
88
+ end
89
+
90
+ private
91
+
92
+ def load_config
93
+ return DEFAULT_CONFIG unless File.exist?(@path)
94
+
95
+ require_relative "utils/config_cache" unless defined?(Utils::ConfigCache)
96
+ cache = Utils::ConfigCache.new(@path)
97
+ cache.load
98
+ rescue StandardError => e
99
+ warn "Error loading config file: #{e.message}"
100
+ warn "Using default configuration"
101
+ DEFAULT_CONFIG
102
+ end
103
+
104
+ def expand_env_vars(path)
105
+ return path unless path.is_a?(String)
106
+
107
+ path.gsub(/\$(\w+)/) do |match|
108
+ var_name = match[1..]
109
+ ENV.fetch(var_name, match)
110
+ end
111
+ end
112
+ end
113
+ end