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,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module GitJump
7
+ # Handles SQLite database operations for branch tracking
8
+ class Database
9
+ attr_reader :db_path
10
+
11
+ def initialize(db_path)
12
+ @db_path = db_path
13
+ @db = nil
14
+ end
15
+
16
+ # Lazy-load database connection
17
+ def db
18
+ @db ||= begin
19
+ require "sqlite3" unless defined?(SQLite3)
20
+ ensure_database_directory!
21
+ connection = SQLite3::Database.new(db_path)
22
+ connection.results_as_hash = true
23
+ migrate!(connection)
24
+ connection
25
+ end
26
+ end
27
+
28
+ def migrate!(connection)
29
+ connection.execute <<-SQL
30
+ CREATE TABLE IF NOT EXISTS projects (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ path TEXT NOT NULL UNIQUE,
33
+ basename TEXT NOT NULL,
34
+ created_at TEXT NOT NULL,
35
+ updated_at TEXT NOT NULL
36
+ );
37
+ SQL
38
+
39
+ connection.execute <<-SQL
40
+ CREATE TABLE IF NOT EXISTS branches (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ project_id INTEGER NOT NULL,
43
+ name TEXT NOT NULL,
44
+ position INTEGER NOT NULL DEFAULT 0,
45
+ last_visited_at TEXT NOT NULL,
46
+ created_at TEXT NOT NULL,
47
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
48
+ UNIQUE(project_id, name)
49
+ );
50
+ SQL
51
+
52
+ connection.execute <<-SQL
53
+ CREATE INDEX IF NOT EXISTS idx_branches_project_position#{" "}
54
+ ON branches(project_id, position);
55
+ SQL
56
+ end
57
+
58
+ def find_or_create_project(path, basename)
59
+ project = db.execute(
60
+ "SELECT * FROM projects WHERE path = ?",
61
+ [path]
62
+ ).first
63
+
64
+ return project if project
65
+
66
+ now = Time.now.iso8601(3)
67
+ db.execute(
68
+ "INSERT INTO projects (path, basename, created_at, updated_at) VALUES (?, ?, ?, ?)",
69
+ [path, basename, now, now]
70
+ )
71
+
72
+ db.execute("SELECT * FROM projects WHERE path = ?", [path]).first
73
+ end
74
+
75
+ def add_branch(project_id, branch_name)
76
+ now = Time.now.iso8601(3)
77
+
78
+ # Try to insert, if already exists, update last_visited_at
79
+ db.execute(
80
+ <<-SQL,
81
+ INSERT INTO branches (project_id, name, position, last_visited_at, created_at)
82
+ VALUES (?, ?, 0, ?, ?)
83
+ ON CONFLICT(project_id, name) DO UPDATE SET
84
+ last_visited_at = ?,
85
+ position = 0
86
+ SQL
87
+ [project_id, branch_name, now, now, now]
88
+ )
89
+
90
+ # Reorder positions based on last_visited_at
91
+ reorder_branches(project_id)
92
+ end
93
+
94
+ def list_branches(project_id, limit: nil)
95
+ sql = "SELECT * FROM branches WHERE project_id = ? ORDER BY position ASC, last_visited_at DESC"
96
+ sql += " LIMIT ?" if limit
97
+
98
+ params = limit ? [project_id, limit] : [project_id]
99
+ db.execute(sql, params)
100
+ end
101
+
102
+ def next_branch(project_id, current_branch)
103
+ branches = list_branches(project_id)
104
+ return nil if branches.empty?
105
+
106
+ current_index = branches.index { |b| b["name"] == current_branch }
107
+
108
+ if current_index.nil?
109
+ branches.first["name"]
110
+ elsif current_index == branches.length - 1
111
+ branches.first["name"]
112
+ else
113
+ branches[current_index + 1]["name"]
114
+ end
115
+ end
116
+
117
+ def branch_at_index(project_id, index)
118
+ branches = list_branches(project_id)
119
+ return nil if branches.empty? || index < 1 || index > branches.length
120
+
121
+ branches[index - 1]["name"]
122
+ end
123
+
124
+ def clear_branches(project_id, keep_patterns)
125
+ return 0 if keep_patterns.empty?
126
+
127
+ branches = list_branches(project_id)
128
+ deleted = 0
129
+
130
+ branches.each do |branch|
131
+ branch_name = branch["name"]
132
+ should_keep = keep_patterns.any? { |pattern| branch_name.match?(Regexp.new(pattern)) }
133
+
134
+ unless should_keep
135
+ db.execute("DELETE FROM branches WHERE id = ?", [branch["id"]])
136
+ deleted += 1
137
+ end
138
+ end
139
+
140
+ reorder_branches(project_id)
141
+ deleted
142
+ end
143
+
144
+ def cleanup_old_branches(project_id, max_count)
145
+ branches = list_branches(project_id)
146
+ return 0 if branches.length <= max_count
147
+
148
+ to_delete = branches[max_count..]
149
+ to_delete.each do |branch|
150
+ db.execute("DELETE FROM branches WHERE id = ?", [branch["id"]])
151
+ end
152
+
153
+ reorder_branches(project_id)
154
+ to_delete.length
155
+ end
156
+
157
+ def count_branches(project_id)
158
+ db.execute(
159
+ "SELECT COUNT(*) as count FROM branches WHERE project_id = ?",
160
+ [project_id]
161
+ ).first["count"]
162
+ end
163
+
164
+ def project_stats(project_id)
165
+ {
166
+ total_branches: count_branches(project_id),
167
+ most_recent: list_branches(project_id, limit: 1).first
168
+ }
169
+ end
170
+
171
+ def close
172
+ db&.close
173
+ end
174
+
175
+ private
176
+
177
+ def ensure_database_directory!
178
+ dir = File.dirname(db_path)
179
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
180
+ end
181
+
182
+ def reorder_branches(project_id)
183
+ branches = db.execute(
184
+ "SELECT id FROM branches WHERE project_id = ? ORDER BY last_visited_at DESC",
185
+ [project_id]
186
+ )
187
+
188
+ branches.each_with_index do |branch, index|
189
+ db.execute("UPDATE branches SET position = ? WHERE id = ?", [index, branch["id"]])
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitJump
4
+ module Hooks
5
+ # Post-checkout hook implementation
6
+ class PostCheckout
7
+ attr_reader :repository_path
8
+
9
+ def initialize(repository_path)
10
+ @repository_path = repository_path
11
+ end
12
+
13
+ def run
14
+ repository = Repository.new(repository_path)
15
+ config = Config.new
16
+
17
+ return unless config.auto_track?
18
+
19
+ database = Database.new(config.database_path)
20
+ project = database.find_or_create_project(
21
+ repository.project_path,
22
+ repository.project_basename
23
+ )
24
+
25
+ current_branch = repository.current_branch
26
+ return unless current_branch && !current_branch.empty?
27
+
28
+ database.add_branch(project["id"], current_branch)
29
+
30
+ # Cleanup old branches if exceeded max
31
+ total = database.count_branches(project["id"])
32
+ database.cleanup_old_branches(project["id"], config.max_branches) if total > config.max_branches
33
+
34
+ database.close
35
+ rescue StandardError
36
+ # Silent fail in hooks to avoid interrupting git operations
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loader for add action
4
+ require_relative "../version"
5
+ require_relative "../utils/xdg"
6
+ require_relative "../utils/output"
7
+ require_relative "../config"
8
+ require_relative "../database"
9
+ require_relative "../repository"
10
+ require_relative "../actions/base"
11
+ require_relative "../actions/add"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loader for clear action
4
+ require_relative "../version"
5
+ require_relative "../utils/xdg"
6
+ require_relative "../utils/output"
7
+ require_relative "../config"
8
+ require_relative "../database"
9
+ require_relative "../repository"
10
+ require_relative "../actions/base"
11
+ require_relative "../actions/clear"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loader for install action
4
+ require_relative "../version"
5
+ require_relative "../utils/xdg"
6
+ require_relative "../utils/output"
7
+ require_relative "../config"
8
+ require_relative "../repository"
9
+ require_relative "../actions/base"
10
+ require_relative "../actions/install"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loader for jump action
4
+ require_relative "../version"
5
+ require_relative "../utils/xdg"
6
+ require_relative "../utils/output"
7
+ require_relative "../config"
8
+ require_relative "../database"
9
+ require_relative "../repository"
10
+ require_relative "../actions/base"
11
+ require_relative "../actions/jump"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loader for list action
4
+ require_relative "../version"
5
+ require_relative "../utils/xdg"
6
+ require_relative "../utils/output"
7
+ require_relative "../config"
8
+ require_relative "../database"
9
+ require_relative "../repository"
10
+ require_relative "../actions/base"
11
+ require_relative "../actions/list"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal loader for setup action - no database needed
4
+ require_relative "../version"
5
+ require_relative "../utils/xdg"
6
+ require_relative "../utils/output"
7
+ require_relative "../config"
8
+ require_relative "../actions/base"
9
+ require_relative "../actions/setup"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loader for status action
4
+ require_relative "../version"
5
+ require_relative "../utils/xdg"
6
+ require_relative "../utils/output"
7
+ require_relative "../config"
8
+ require_relative "../database"
9
+ require_relative "../repository"
10
+ require_relative "../actions/base"
11
+ require_relative "../actions/status"
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "fileutils"
5
+ require "shellwords"
6
+
7
+ module GitJump
8
+ # Wrapper for git repository operations
9
+ class Repository
10
+ class NotAGitRepositoryError < StandardError; end
11
+
12
+ attr_reader :path
13
+
14
+ def initialize(path = Dir.pwd)
15
+ @path = File.expand_path(path)
16
+ raise NotAGitRepositoryError, "Not a git repository: #{@path}" unless valid?
17
+ end
18
+
19
+ def valid?
20
+ git_dir = File.join(@path, ".git")
21
+ File.directory?(git_dir) || find_git_root
22
+ end
23
+
24
+ def project_path
25
+ @project_path ||= find_git_root || @path
26
+ end
27
+
28
+ def project_basename
29
+ File.basename(project_path)
30
+ end
31
+
32
+ def current_branch
33
+ result = execute_git("branch", "--show-current")
34
+ result.strip
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ def branches
40
+ result = execute_git("branch", "--format=%(refname:short)")
41
+ result.split("\n").map(&:strip)
42
+ rescue StandardError
43
+ []
44
+ end
45
+
46
+ def checkout(branch_name)
47
+ # Set environment variable to skip git-jump hook during checkout
48
+ # This prevents double-loading of gems when git-jump triggers checkout
49
+ ENV["GIT_JUMP_SKIP_HOOK"] = "1"
50
+ execute_git("checkout", branch_name)
51
+ true
52
+ rescue StandardError => e
53
+ raise "Failed to checkout branch '#{branch_name}': #{e.message}"
54
+ ensure
55
+ ENV.delete("GIT_JUMP_SKIP_HOOK")
56
+ end
57
+
58
+ def branch_exists?(branch_name)
59
+ branches.include?(branch_name)
60
+ end
61
+
62
+ def hook_path(hook_name)
63
+ File.join(project_path, ".git", "hooks", hook_name)
64
+ end
65
+
66
+ def install_hook(hook_name, content)
67
+ path = hook_path(hook_name)
68
+ hooks_dir = File.dirname(path)
69
+
70
+ # Create hooks directory if it doesn't exist
71
+ FileUtils.mkdir_p(hooks_dir) unless File.directory?(hooks_dir)
72
+
73
+ File.write(path, content)
74
+ FileUtils.chmod(0o755, path)
75
+
76
+ true
77
+ rescue StandardError => e
78
+ raise "Failed to install hook '#{hook_name}': #{e.message}"
79
+ end
80
+
81
+ def hook_installed?(hook_name)
82
+ path = hook_path(hook_name)
83
+ File.exist?(path) && File.executable?(path)
84
+ end
85
+
86
+ def read_hook(hook_name)
87
+ File.read(hook_path(hook_name))
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ private
93
+
94
+ def find_git_root
95
+ current = @path
96
+
97
+ loop do
98
+ return current if File.directory?(File.join(current, ".git"))
99
+
100
+ parent = File.dirname(current)
101
+ break if parent == current # reached root
102
+
103
+ current = parent
104
+ end
105
+
106
+ nil
107
+ end
108
+
109
+ def execute_git(*args)
110
+ Dir.chdir(project_path) do
111
+ cmd = ["git"] + args
112
+ output = `#{cmd.map(&:shellescape).join(" ")} 2>&1`
113
+ raise "Git command failed: #{output}" unless $CHILD_STATUS.success?
114
+
115
+ output
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+
7
+ module GitJump
8
+ module Utils
9
+ # Caches parsed TOML configuration for faster startup
10
+ # Inspired by dotsync's ConfigCache implementation
11
+ class ConfigCache
12
+ attr_reader :config_path, :cache_dir, :cache_file, :meta_file
13
+
14
+ def initialize(config_path)
15
+ @config_path = File.expand_path(config_path)
16
+ @cache_dir = File.join(Utils::XDG.data_home, "git-jump", "config_cache")
17
+
18
+ # Use hash of real path for cache filename to support multiple configs
19
+ cache_key = Digest::SHA256.hexdigest(File.exist?(@config_path) ? File.realpath(@config_path) : @config_path)
20
+ @cache_file = File.join(@cache_dir, "#{cache_key}.cache")
21
+ @meta_file = File.join(@cache_dir, "#{cache_key}.meta")
22
+ end
23
+
24
+ def load
25
+ # Skip cache if disabled via environment variable
26
+ return parse_toml if ENV["GIT_JUMP_NO_CACHE"]
27
+
28
+ return parse_and_cache unless valid_cache?
29
+
30
+ # Fast path: load from cache
31
+ Marshal.load(File.binread(@cache_file))
32
+ rescue StandardError
33
+ # Fallback: reparse if cache corrupted or any error
34
+ parse_and_cache
35
+ end
36
+
37
+ private
38
+
39
+ def valid_cache?
40
+ return false unless File.exist?(@cache_file)
41
+ return false unless File.exist?(@meta_file)
42
+ return false unless File.exist?(@config_path)
43
+
44
+ meta = JSON.parse(File.read(@meta_file))
45
+ source_stat = File.stat(@config_path)
46
+
47
+ # Quick validation checks
48
+ return false if source_stat.mtime.to_f != meta["source_mtime"]
49
+ return false if source_stat.size != meta["source_size"]
50
+ return false if meta["git_jump_version"] != GitJump::VERSION
51
+
52
+ # Age check (invalidate cache older than 7 days for safety)
53
+ cache_age_days = (Time.now.to_f - meta["cached_at"]) / 86_400
54
+ return false if cache_age_days > 7
55
+
56
+ true
57
+ rescue StandardError
58
+ # Any error in validation means invalid cache
59
+ false
60
+ end
61
+
62
+ def parse_and_cache
63
+ config = parse_toml
64
+
65
+ # Write cache files
66
+ FileUtils.mkdir_p(@cache_dir)
67
+ File.binwrite(@cache_file, Marshal.dump(config))
68
+ File.write(@meta_file, JSON.generate(build_metadata))
69
+
70
+ config
71
+ rescue StandardError
72
+ # If caching fails, still return the parsed config
73
+ config
74
+ end
75
+
76
+ def parse_toml
77
+ require "toml-rb" unless defined?(TomlRB)
78
+ TomlRB.load_file(@config_path)
79
+ end
80
+
81
+ def build_metadata
82
+ source_stat = File.stat(@config_path)
83
+ {
84
+ source_path: @config_path,
85
+ source_size: source_stat.size,
86
+ source_mtime: source_stat.mtime.to_f,
87
+ cached_at: Time.now.to_f,
88
+ git_jump_version: GitJump::VERSION
89
+ }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../colors"
4
+
5
+ module GitJump
6
+ module Utils
7
+ # Handles formatted console output with colors and tables
8
+ class Output
9
+ attr_reader :quiet, :verbose
10
+
11
+ def initialize(quiet: false, verbose: false)
12
+ @quiet = quiet
13
+ @verbose = verbose
14
+ end
15
+
16
+ def success(message)
17
+ puts Colors.green("✓ #{message}") unless quiet
18
+ end
19
+
20
+ def error(message)
21
+ warn Colors.red("✗ #{message}")
22
+ end
23
+
24
+ def warning(message)
25
+ puts Colors.yellow("⚠ #{message}") unless quiet
26
+ end
27
+
28
+ def info(message)
29
+ puts Colors.blue("ℹ #{message}") unless quiet
30
+ end
31
+
32
+ def debug(message)
33
+ puts Colors.dim(message) if verbose
34
+ end
35
+
36
+ def heading(message)
37
+ puts unless quiet
38
+ puts Colors.cyan(message, bold: true) unless quiet
39
+ puts Colors.dim("─" * message.length) unless quiet
40
+ end
41
+
42
+ def table(headers, rows)
43
+ return if quiet
44
+
45
+ require "terminal-table" unless defined?(Terminal::Table)
46
+ table = Terminal::Table.new(headings: headers, rows: rows)
47
+ puts table
48
+ end
49
+
50
+ def branch_list(branches, current_branch)
51
+ return if quiet || branches.empty?
52
+
53
+ heading("Tracked Branches")
54
+
55
+ rows = branches.map.with_index(1) do |branch, index|
56
+ name = branch["name"]
57
+ marker = name == current_branch ? Colors.green("→") : " "
58
+ styled_name = name == current_branch ? Colors.green(name, bold: true) : name
59
+ last_visited = format_time(branch["last_visited_at"])
60
+
61
+ ["#{marker} #{index}", styled_name, last_visited]
62
+ end
63
+
64
+ table(["#", "Branch", "Last Visited"], rows)
65
+ end
66
+
67
+ def prompt(message, _default: "N")
68
+ return true if quiet # Auto-confirm in quiet mode
69
+
70
+ print Colors.yellow("#{message} [y/N] ")
71
+ answer = $stdin.gets&.chomp&.downcase
72
+ %w[y yes].include?(answer)
73
+ end
74
+
75
+ private
76
+
77
+ def format_time(time_string)
78
+ time = Time.parse(time_string)
79
+ diff = Time.now - time
80
+
81
+ case diff
82
+ when 0..59
83
+ "just now"
84
+ when 60..3599
85
+ "#{(diff / 60).to_i}m ago"
86
+ when 3600..86_399
87
+ "#{(diff / 3600).to_i}h ago"
88
+ when 86_400..2_591_999
89
+ "#{(diff / 86_400).to_i}d ago"
90
+ else
91
+ time.strftime("%Y-%m-%d")
92
+ end
93
+ rescue StandardError
94
+ "unknown"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module GitJump
6
+ module Utils
7
+ # XDG Base Directory Specification support
8
+ # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
9
+ module XDG
10
+ class << self
11
+ def config_home
12
+ ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config"))
13
+ end
14
+
15
+ def data_home
16
+ ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share"))
17
+ end
18
+
19
+ def cache_home
20
+ ENV.fetch("XDG_CACHE_HOME", File.join(Dir.home, ".cache"))
21
+ end
22
+
23
+ def config_path(custom_path = nil)
24
+ return custom_path if custom_path
25
+
26
+ File.join(config_home, "git-jump", "config.toml")
27
+ end
28
+
29
+ def database_path
30
+ File.join(data_home, "git-jump", "branches.db")
31
+ end
32
+
33
+ def ensure_directories!
34
+ [config_dir, data_dir, cache_dir].each do |dir|
35
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def config_dir
42
+ File.join(config_home, "git-jump")
43
+ end
44
+
45
+ def data_dir
46
+ File.join(data_home, "git-jump")
47
+ end
48
+
49
+ def cache_dir
50
+ File.join(cache_home, "git-jump")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitJump
4
+ VERSION = "0.1.0"
5
+ end