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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +355 -0
- data/Rakefile +33 -0
- data/db/schema.rb +18 -0
- data/exe/git-jump +10 -0
- data/lib/git_jump/action.rb +43 -0
- data/lib/git_jump/actions/add.rb +36 -0
- data/lib/git_jump/actions/base.rb +34 -0
- data/lib/git_jump/actions/clear.rb +44 -0
- data/lib/git_jump/actions/install.rb +68 -0
- data/lib/git_jump/actions/jump.rb +53 -0
- data/lib/git_jump/actions/list.rb +25 -0
- data/lib/git_jump/actions/setup.rb +34 -0
- data/lib/git_jump/actions/status.rb +38 -0
- data/lib/git_jump/cli.rb +232 -0
- data/lib/git_jump/colors.rb +56 -0
- data/lib/git_jump/config.rb +113 -0
- data/lib/git_jump/database.rb +193 -0
- data/lib/git_jump/hooks/post_checkout.rb +41 -0
- data/lib/git_jump/loaders/add_loader.rb +11 -0
- data/lib/git_jump/loaders/clear_loader.rb +11 -0
- data/lib/git_jump/loaders/install_loader.rb +10 -0
- data/lib/git_jump/loaders/jump_loader.rb +11 -0
- data/lib/git_jump/loaders/list_loader.rb +11 -0
- data/lib/git_jump/loaders/setup_loader.rb +9 -0
- data/lib/git_jump/loaders/status_loader.rb +11 -0
- data/lib/git_jump/repository.rb +119 -0
- data/lib/git_jump/utils/config_cache.rb +93 -0
- data/lib/git_jump/utils/output.rb +98 -0
- data/lib/git_jump/utils/xdg.rb +55 -0
- data/lib/git_jump/version.rb +5 -0
- data/lib/git_jump.rb +49 -0
- data/sig/git/jump.rbs +6 -0
- metadata +193 -0
|
@@ -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
|