dev_context 1.0.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 +18 -0
- data/DESIGN.md +250 -0
- data/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/bin/console +11 -0
- data/bin/dev_context.rb +6 -0
- data/bin/setup +8 -0
- data/lib/dev_context/cli.rb +103 -0
- data/lib/dev_context/commands/context_lifecycle.rb +80 -0
- data/lib/dev_context/commands/find.rb +123 -0
- data/lib/dev_context/commands/git_ops.rb +163 -0
- data/lib/dev_context/commands/help.rb +81 -0
- data/lib/dev_context/commands/init.rb +22 -0
- data/lib/dev_context/commands/remove.rb +22 -0
- data/lib/dev_context/commands/repos.rb +42 -0
- data/lib/dev_context/commands/search_helpers.rb +53 -0
- data/lib/dev_context/commands/stashes.rb +50 -0
- data/lib/dev_context/commands/status.rb +100 -0
- data/lib/dev_context/commands/support.rb +361 -0
- data/lib/dev_context/config.rb +123 -0
- data/lib/dev_context/matcher.rb +56 -0
- data/lib/dev_context/shell_emitter.rb +61 -0
- data/lib/dev_context/shell_setup.rb +59 -0
- data/lib/dev_context/status.rb +51 -0
- data/lib/dev_context/version.rb +8 -0
- data/lib/dev_context.rb +24 -0
- metadata +116 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DevContext
|
|
4
|
+
module Commands
|
|
5
|
+
module Status
|
|
6
|
+
def cmd_status(mode: "status")
|
|
7
|
+
args = argv.dup
|
|
8
|
+
all = false
|
|
9
|
+
dirty_only = (mode == "wip")
|
|
10
|
+
branch_pattern = nil
|
|
11
|
+
path_pattern = nil
|
|
12
|
+
parser = OptionParser.new
|
|
13
|
+
parser.on("-a", "--all", "Show status for all known repos") { all = true }
|
|
14
|
+
parser.on("-d", "--dirty", "Show only repos that are not up to date") { dirty_only = true }
|
|
15
|
+
parser.on("-b", "--branch BRANCHPATTERN", "Filter by branch pattern") { |v| branch_pattern = v }
|
|
16
|
+
parser.on("-p", "--path PATHPATTERN", "Filter by path pattern") { |v| path_pattern = v }
|
|
17
|
+
parser.parse!(args)
|
|
18
|
+
usage = mode == "wip" ? "dx wip [--all] [--dirty] [-b BRANCHPATTERN] [-p PATHPATTERN]" : "dx status [--all] [--dirty] [-b BRANCHPATTERN] [-p PATHPATTERN]"
|
|
19
|
+
return usage_error(usage) unless args.empty?
|
|
20
|
+
|
|
21
|
+
rows = all ? all_known_repo_rows : active_context_rows
|
|
22
|
+
rows = rows.select { |row| pattern_match?(row[:branch], branch_pattern) }
|
|
23
|
+
rows = rows.select { |row| pattern_match?(row[:path], path_pattern) }
|
|
24
|
+
rows = rows.reject { |row| strip_ansi(row[:status]).start_with?("Up to date") } if dirty_only
|
|
25
|
+
if rows.empty?
|
|
26
|
+
out.puts(empty_message(all: all, dirty_only: dirty_only))
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
render_status_table(rows)
|
|
31
|
+
0
|
|
32
|
+
rescue OptionParser::InvalidOption => e
|
|
33
|
+
err.puts("dx: #{e.message}")
|
|
34
|
+
usage_error(mode == "wip" ? "dx wip [--all] [--dirty] [-b BRANCHPATTERN] [-p PATHPATTERN]" : "dx status [--all] [--dirty] [-b BRANCHPATTERN] [-p PATHPATTERN]")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def active_context_rows
|
|
40
|
+
config.active_contexts.map do |ctx|
|
|
41
|
+
stash_count = stash_count(ctx.fetch("repo_path"))
|
|
42
|
+
base_status = ::DevContext::Status.new(repo_path: ctx.fetch("repo_path")).one_line
|
|
43
|
+
{
|
|
44
|
+
path: ctx.fetch("repo_path"),
|
|
45
|
+
branch: ctx.fetch("branch"),
|
|
46
|
+
status: colorize_status(compact_status(with_stash(base_status, stash_count)))
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def all_known_repo_rows
|
|
52
|
+
config.repos.values.map { |repo| repo["path"] }.uniq.filter_map do |path|
|
|
53
|
+
expanded = File.expand_path(path)
|
|
54
|
+
next unless File.directory?(expanded)
|
|
55
|
+
stash = stash_count(expanded)
|
|
56
|
+
base_status = ::DevContext::Status.new(repo_path: expanded).one_line
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
path: expanded,
|
|
60
|
+
branch: current_branch(expanded) || "-",
|
|
61
|
+
status: colorize_status(compact_status(with_stash(base_status, stash)))
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def strip_ansi(text)
|
|
67
|
+
text.to_s.gsub(/\e\[[0-9;]*m/, "")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def empty_message(all:, dirty_only:)
|
|
71
|
+
return "No dirty repos" if all && dirty_only
|
|
72
|
+
return "No dirty active contexts" if dirty_only
|
|
73
|
+
return "No known repos" if all
|
|
74
|
+
|
|
75
|
+
"No active contexts"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def stash_count(repo_path)
|
|
79
|
+
stash_entries(repo_path).length
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def with_stash(status, stash_count)
|
|
83
|
+
return status if stash_count.to_i.zero?
|
|
84
|
+
|
|
85
|
+
"#{status} s:#{stash_count}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def compact_status(status)
|
|
89
|
+
return status if status.start_with?("Up to date")
|
|
90
|
+
|
|
91
|
+
filtered = status
|
|
92
|
+
.split
|
|
93
|
+
.reject { |token| token.match?(/\A[a-z]:0\z/) }
|
|
94
|
+
|
|
95
|
+
filtered.empty? ? "Up to date" : filtered.join(" ")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DevContext
|
|
4
|
+
module Commands
|
|
5
|
+
module Support
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def current_branch(repo_path)
|
|
9
|
+
cmd = %(git -C "#{File.expand_path(repo_path)}" rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
10
|
+
branch = `#{cmd}`.strip
|
|
11
|
+
return nil unless $?.success?
|
|
12
|
+
|
|
13
|
+
branch
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resolve_or_create_context(target)
|
|
17
|
+
existing = matcher.resolve(target)
|
|
18
|
+
return [existing, 0] if existing
|
|
19
|
+
|
|
20
|
+
if url?(target)
|
|
21
|
+
code = clone_and_register!(target, explicit_path: nil, explicit_name: nil, opts: {})
|
|
22
|
+
return [nil, code] unless code.zero?
|
|
23
|
+
|
|
24
|
+
destination = clone_destination(target, nil)
|
|
25
|
+
context = config.contexts.values.find { |ctx| ctx["repo_path"] == destination }
|
|
26
|
+
return [context, 0] if context
|
|
27
|
+
|
|
28
|
+
return [nil, not_found(target)]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
resolved = resolve_repo_path_target(target)
|
|
32
|
+
return [nil, resolved.fetch(:code)] unless resolved.fetch(:code).zero?
|
|
33
|
+
|
|
34
|
+
repo_path = resolved.fetch(:path)
|
|
35
|
+
return [nil, repo_not_found(repo_path)] unless File.directory?(repo_path)
|
|
36
|
+
|
|
37
|
+
unless git_repo?(repo_path)
|
|
38
|
+
init_code = ensure_git_repo!(repo_path)
|
|
39
|
+
return [nil, init_code] unless init_code.zero?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
branch = current_branch(repo_path) || "main"
|
|
43
|
+
context_name = config.add_context(
|
|
44
|
+
name: implicit_context_name(repo_path, branch),
|
|
45
|
+
repo_path: repo_path,
|
|
46
|
+
branch: branch
|
|
47
|
+
)
|
|
48
|
+
[config.get_context(context_name), 0]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clone_and_register!(url, explicit_path:, explicit_name: nil, opts: {})
|
|
52
|
+
destination = clone_destination(url, explicit_path)
|
|
53
|
+
destination_parent = File.dirname(destination)
|
|
54
|
+
if opts[:norun]
|
|
55
|
+
out.puts("Would clone #{url} -> #{destination}")
|
|
56
|
+
out.puts("Would register context #{(explicit_name || implicit_context_name(destination, 'main')).downcase}")
|
|
57
|
+
return 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
FileUtils.mkdir_p(destination_parent) unless File.directory?(destination_parent)
|
|
61
|
+
|
|
62
|
+
if File.exist?(destination)
|
|
63
|
+
return non_git_path(destination) unless git_repo?(destination)
|
|
64
|
+
else
|
|
65
|
+
clone_cmd = %(git clone #{Shellwords.escape(url)} #{Shellwords.escape(destination)} 2>&1)
|
|
66
|
+
clone_output = `#{clone_cmd}`
|
|
67
|
+
unless $?.success?
|
|
68
|
+
err.puts("dx: clone failed for #{url}")
|
|
69
|
+
err.puts(clone_output)
|
|
70
|
+
return 21
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
branch = current_branch(destination) || "main"
|
|
75
|
+
context_name = config.add_context(
|
|
76
|
+
name: explicit_name || implicit_context_name(destination, branch),
|
|
77
|
+
repo_path: destination,
|
|
78
|
+
branch: branch
|
|
79
|
+
)
|
|
80
|
+
out.puts("Clone destination: #{destination}") if opts[:verbose]
|
|
81
|
+
out.puts("Cloned/added context #{context_name} -> #{destination} @ #{branch}")
|
|
82
|
+
0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def add_single_target(target, explicit_context: nil, opts: {})
|
|
86
|
+
if url?(target)
|
|
87
|
+
return clone_and_register!(target, explicit_path: nil, explicit_name: explicit_context, opts: opts)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
resolved = resolve_repo_path_target(target)
|
|
91
|
+
return resolved.fetch(:code) unless resolved.fetch(:code).zero?
|
|
92
|
+
|
|
93
|
+
repo_path = resolved.fetch(:path)
|
|
94
|
+
return repo_not_found(repo_path) unless File.directory?(repo_path)
|
|
95
|
+
if opts[:norun]
|
|
96
|
+
branch_preview = current_branch(repo_path) || "main"
|
|
97
|
+
preview_name = explicit_context || implicit_context_name(repo_path, branch_preview)
|
|
98
|
+
out.puts("Would add context #{preview_name.downcase} -> #{repo_path} @ #{branch_preview}")
|
|
99
|
+
return 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
unless git_repo?(repo_path)
|
|
103
|
+
init_code = ensure_git_repo!(repo_path)
|
|
104
|
+
return init_code unless init_code.zero?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
branch = current_branch(repo_path) || "main"
|
|
108
|
+
context_name = config.add_context(
|
|
109
|
+
name: explicit_context || implicit_context_name(repo_path, branch),
|
|
110
|
+
repo_path: repo_path,
|
|
111
|
+
branch: branch
|
|
112
|
+
)
|
|
113
|
+
out.puts("Resolved path: #{repo_path}") if opts[:verbose]
|
|
114
|
+
out.puts("Added context #{context_name} -> #{repo_path} @ #{branch}")
|
|
115
|
+
0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def resolve_repo_path_target(target)
|
|
119
|
+
direct = File.expand_path(target, pwd)
|
|
120
|
+
return { code: 0, path: direct } if File.directory?(direct)
|
|
121
|
+
|
|
122
|
+
matches = path_search_roots
|
|
123
|
+
.map { |root| File.expand_path(target, root) }
|
|
124
|
+
.select { |candidate| File.directory?(candidate) }
|
|
125
|
+
.uniq
|
|
126
|
+
|
|
127
|
+
return { code: 0, path: matches.first } if matches.length == 1
|
|
128
|
+
|
|
129
|
+
if matches.length > 1
|
|
130
|
+
err.puts("dx: '#{target}' is ambiguous in DX_PATH:")
|
|
131
|
+
matches.each { |match| err.puts(" - #{match}") }
|
|
132
|
+
return { code: 26 }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
fsf_matches = fsf_repo_matches(target)
|
|
136
|
+
return { code: 0, path: fsf_matches.first } if fsf_matches.length == 1
|
|
137
|
+
|
|
138
|
+
if fsf_matches.length > 1
|
|
139
|
+
err.puts("dx: '#{target}' is ambiguous by FSF search across DX_PATH/DX_CLONE_BASE_DIR:")
|
|
140
|
+
fsf_matches.each { |match| err.puts(" - #{match}") }
|
|
141
|
+
return { code: 27 }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
{ code: 0, path: direct }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_common_flags!(args, allow_context:, allow_norun:)
|
|
148
|
+
opts = { context: nil, norun: false, verbose: false }
|
|
149
|
+
parser = OptionParser.new
|
|
150
|
+
parser.on("-v", "--verbose", "Verbose output") { opts[:verbose] = true }
|
|
151
|
+
parser.on("-n", "--norun", "Preview actions without mutating state") do
|
|
152
|
+
raise OptionParser::InvalidOption, "--norun not supported for this command" unless allow_norun
|
|
153
|
+
|
|
154
|
+
opts[:norun] = true
|
|
155
|
+
end
|
|
156
|
+
parser.on("-cNAME", "--context=NAME", "Explicit context name") do |name|
|
|
157
|
+
raise OptionParser::InvalidOption, "--context not supported for this command" unless allow_context
|
|
158
|
+
|
|
159
|
+
opts[:context] = config.normalize_name(name)
|
|
160
|
+
end
|
|
161
|
+
parser.parse!(args)
|
|
162
|
+
opts
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def clone_destination(url, explicit_path)
|
|
166
|
+
suffix = explicit_path || url_basename(url)
|
|
167
|
+
return File.expand_path(suffix) if Pathname.new(suffix).absolute?
|
|
168
|
+
|
|
169
|
+
File.expand_path(suffix, clone_base_dir)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def clone_base_dir
|
|
173
|
+
configured = env["DX_CLONE_BASE_DIR"]
|
|
174
|
+
return File.expand_path(configured) if configured && File.directory?(File.expand_path(configured))
|
|
175
|
+
|
|
176
|
+
pwd
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def dx_path_roots
|
|
180
|
+
raw = env["DX_PATH"]
|
|
181
|
+
return [] if blank?(raw)
|
|
182
|
+
|
|
183
|
+
raw.split(/[:;]/)
|
|
184
|
+
.flat_map { |entry| Dir.glob(File.expand_path(entry.to_s.strip)) }
|
|
185
|
+
.select { |entry| File.directory?(entry) }
|
|
186
|
+
.uniq
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def path_search_roots
|
|
190
|
+
roots = dx_path_roots.dup
|
|
191
|
+
clone_root = env["DX_CLONE_BASE_DIR"]
|
|
192
|
+
if clone_root
|
|
193
|
+
expanded = File.expand_path(clone_root)
|
|
194
|
+
roots << expanded if File.directory?(expanded)
|
|
195
|
+
end
|
|
196
|
+
roots.uniq
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def fsf_repo_matches(query)
|
|
200
|
+
normalized = query.to_s.downcase
|
|
201
|
+
path_search_roots.flat_map do |root|
|
|
202
|
+
Dir.children(root).filter_map do |entry|
|
|
203
|
+
candidate = File.join(root, entry)
|
|
204
|
+
next unless File.directory?(candidate)
|
|
205
|
+
next unless fsf_subsequence?(normalized, entry.downcase)
|
|
206
|
+
|
|
207
|
+
candidate
|
|
208
|
+
end
|
|
209
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
210
|
+
[]
|
|
211
|
+
end.uniq
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def fsf_subsequence?(needle, haystack)
|
|
215
|
+
return false if needle.empty?
|
|
216
|
+
|
|
217
|
+
i = 0
|
|
218
|
+
haystack.each_char do |ch|
|
|
219
|
+
i += 1 if i < needle.length && needle[i] == ch
|
|
220
|
+
end
|
|
221
|
+
i == needle.length
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def url?(value)
|
|
225
|
+
value.match?(%r{\A[a-z][a-z0-9+\-.]*://}i) || value.start_with?("git@")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def url_basename(url)
|
|
229
|
+
base = url.split("/").last.to_s
|
|
230
|
+
base = base.split(":").last if base.empty?
|
|
231
|
+
base.sub(/\.git\z/, "")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def implicit_context_name(repo_path, branch)
|
|
235
|
+
"#{File.basename(repo_path)}:#{branch}".downcase
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def git_repo?(path)
|
|
239
|
+
cmd = %(git -C "#{path}" rev-parse --is-inside-work-tree >/dev/null 2>&1)
|
|
240
|
+
system(cmd)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def ensure_git_repo!(repo_path)
|
|
244
|
+
if truthy?(env.fetch("DX_NO_GIT_INIT", "false"))
|
|
245
|
+
err.puts("dx: #{repo_path} is not a git repository; set DX_NO_GIT_INIT=false or run `git init #{repo_path}`")
|
|
246
|
+
return 23
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
init_cmd = %(git -C "#{repo_path}" init >/dev/null 2>&1)
|
|
250
|
+
unless system(init_cmd)
|
|
251
|
+
err.puts("dx: failed to initialize git repository at #{repo_path}")
|
|
252
|
+
return 24
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
out.puts("Initialized git repository at #{repo_path}")
|
|
256
|
+
0
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def blank?(value)
|
|
260
|
+
value.to_s.strip.empty?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def truthy?(value)
|
|
264
|
+
%w[1 true yes y on].include?(value.to_s.strip.downcase)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def not_found(target)
|
|
268
|
+
err.puts("dx: could not resolve context/path '#{target}'")
|
|
269
|
+
1
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def repo_not_found(path)
|
|
273
|
+
err.puts("dx: repository path does not exist: #{path}")
|
|
274
|
+
1
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def non_git_path(path)
|
|
278
|
+
err.puts("dx: destination exists but is not a git repository: #{path}")
|
|
279
|
+
22
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def render_status_table(rows)
|
|
283
|
+
display_rows = rows.map do |row|
|
|
284
|
+
row.merge(path: display_path(row[:path]))
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
path_w = [display_rows.map { |r| r[:path].length }.max || 0, "Path".length].max
|
|
288
|
+
branch_w = [rows.map { |r| r[:branch].length }.max || 0, "Branch".length].max
|
|
289
|
+
|
|
290
|
+
out.puts(format("%-#{path_w}s %-#{branch_w}s %s", "Path", "Branch", "Status"))
|
|
291
|
+
out.puts(format("%-#{path_w}s %-#{branch_w}s %s", "-" * path_w, "-" * branch_w, "-" * 6))
|
|
292
|
+
display_rows.each do |row|
|
|
293
|
+
out.puts(format("%-#{path_w}s %-#{branch_w}s %s", row[:path], row[:branch], row[:status]))
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def display_path(path)
|
|
298
|
+
home = File.expand_path("~")
|
|
299
|
+
expanded = File.expand_path(path)
|
|
300
|
+
return "~" if expanded == home
|
|
301
|
+
return expanded unless expanded.start_with?("#{home}/")
|
|
302
|
+
|
|
303
|
+
expanded.sub(home, "~")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def colorize_status(status)
|
|
307
|
+
return status unless color_output?
|
|
308
|
+
color_orange = self.class::COLOR_ORANGE
|
|
309
|
+
color_cyan = self.class::COLOR_CYAN
|
|
310
|
+
color_green = self.class::COLOR_GREEN
|
|
311
|
+
color_red = self.class::COLOR_RED
|
|
312
|
+
color_yellow = self.class::COLOR_YELLOW
|
|
313
|
+
color_magenta = self.class::COLOR_MAGENTA
|
|
314
|
+
color_reset = self.class::COLOR_RESET
|
|
315
|
+
return "#{color_green}Up to date#{color_reset}" if status == "Up to date"
|
|
316
|
+
|
|
317
|
+
status
|
|
318
|
+
.gsub(/\bm:\d+\b/, "#{color_orange}\\0#{color_reset}")
|
|
319
|
+
.gsub(/\bu:\d+\b/, "#{color_cyan}\\0#{color_reset}")
|
|
320
|
+
.gsub(/\bn:\d+\b/, "#{color_green}\\0#{color_reset}")
|
|
321
|
+
.gsub(/\bd:\d+\b/, "#{color_red}\\0#{color_reset}")
|
|
322
|
+
.gsub(/\br:\d+\b/, "#{color_yellow}\\0#{color_reset}")
|
|
323
|
+
.gsub(/\bs:\d+\b/, "#{color_magenta}\\0#{color_reset}")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def color_output?
|
|
327
|
+
return false if env.key?("NO_COLOR")
|
|
328
|
+
return true if truthy?(env.fetch("DX_COLOR", "false"))
|
|
329
|
+
return true if env["DX_SHELL_WRAPPED"] == "1"
|
|
330
|
+
|
|
331
|
+
out.tty?
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def usage_error(message)
|
|
335
|
+
err.puts("Usage: #{message}")
|
|
336
|
+
2
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def resolve_command(raw)
|
|
340
|
+
return { command: raw, error: nil } if %w[--help -h].include?(raw)
|
|
341
|
+
commands = self.class::COMMANDS
|
|
342
|
+
return { command: raw, error: nil } if commands.include?(raw)
|
|
343
|
+
|
|
344
|
+
matches = commands.select { |cmd| cmd.start_with?(raw) }
|
|
345
|
+
if matches.empty?
|
|
346
|
+
err.puts("dx: unknown command '#{raw}'")
|
|
347
|
+
cmd_help
|
|
348
|
+
return { command: nil, error: :unknown }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
if matches.length > 1
|
|
352
|
+
err.puts("dx: ambiguous command abbreviation '#{raw}'")
|
|
353
|
+
err.puts("dx: could match: #{matches.join(', ')}")
|
|
354
|
+
return { command: nil, error: :ambiguous }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
{ command: matches.first, error: nil }
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module DevContext
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULT_PATH = File.expand_path("~/.dxcf").freeze
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
"version" => 1,
|
|
11
|
+
"contexts" => {},
|
|
12
|
+
"active_stack" => [],
|
|
13
|
+
"repos" => {},
|
|
14
|
+
"clone_roots" => []
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :path, :data
|
|
18
|
+
|
|
19
|
+
def initialize(path: ENV.fetch("DX_CONFIG_PATH", DEFAULT_PATH))
|
|
20
|
+
@path = path
|
|
21
|
+
@data = load_data
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialized?
|
|
25
|
+
File.exist?(path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def init!
|
|
29
|
+
return false if initialized?
|
|
30
|
+
|
|
31
|
+
@data = DEFAULTS.dup
|
|
32
|
+
save!
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def contexts
|
|
37
|
+
data.fetch("contexts")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def repos
|
|
41
|
+
data.fetch("repos")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def active_stack
|
|
45
|
+
data.fetch("active_stack")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def add_context(name:, repo_path:, branch:)
|
|
49
|
+
timestamp = Time.now.utc.iso8601
|
|
50
|
+
context_name = normalize_name(name)
|
|
51
|
+
|
|
52
|
+
contexts[context_name] = {
|
|
53
|
+
"name" => context_name,
|
|
54
|
+
"repo_path" => File.expand_path(repo_path),
|
|
55
|
+
"branch" => branch,
|
|
56
|
+
"created_at" => contexts[context_name]&.fetch("created_at", timestamp) || timestamp,
|
|
57
|
+
"last_used_at" => timestamp
|
|
58
|
+
}
|
|
59
|
+
touch_repo!(repo_path)
|
|
60
|
+
save!
|
|
61
|
+
context_name
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def get_context(name_or_path)
|
|
65
|
+
key = normalize_name(name_or_path)
|
|
66
|
+
contexts[key] || contexts.values.find { |ctx| ctx["repo_path"] == File.expand_path(name_or_path) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def activate_context!(context_name)
|
|
70
|
+
name = normalize_name(context_name)
|
|
71
|
+
context = contexts.fetch(name)
|
|
72
|
+
|
|
73
|
+
active_stack.delete(name)
|
|
74
|
+
active_stack.unshift(name)
|
|
75
|
+
context["last_used_at"] = Time.now.utc.iso8601
|
|
76
|
+
save!
|
|
77
|
+
context
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def deactivate_context!(context_name)
|
|
81
|
+
name = normalize_name(context_name)
|
|
82
|
+
removed = active_stack.delete(name)
|
|
83
|
+
save! if removed
|
|
84
|
+
!!removed
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def active_contexts
|
|
88
|
+
active_stack.filter_map { |name| contexts[name] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def normalize_name(name)
|
|
92
|
+
name.to_s.strip.downcase
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def load_data
|
|
98
|
+
return DEFAULTS.dup unless File.exist?(path)
|
|
99
|
+
|
|
100
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Time], aliases: true) || {}
|
|
101
|
+
merged = DEFAULTS.merge(raw)
|
|
102
|
+
merged["contexts"] ||= {}
|
|
103
|
+
merged["active_stack"] ||= []
|
|
104
|
+
merged["repos"] ||= {}
|
|
105
|
+
merged["clone_roots"] ||= []
|
|
106
|
+
merged
|
|
107
|
+
rescue Psych::SyntaxError => e
|
|
108
|
+
raise Error, "Invalid ~/.dxcf YAML: #{e.message}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def save!
|
|
112
|
+
File.write(path, YAML.dump(data))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def touch_repo!(repo_path)
|
|
116
|
+
basename = File.basename(File.expand_path(repo_path))
|
|
117
|
+
data["repos"][basename] = {
|
|
118
|
+
"path" => File.expand_path(repo_path),
|
|
119
|
+
"last_seen_at" => Time.now.utc.iso8601
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DevContext
|
|
4
|
+
class Matcher
|
|
5
|
+
def initialize(config:)
|
|
6
|
+
@config = config
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def resolve(query)
|
|
10
|
+
return nil if query.to_s.strip.empty?
|
|
11
|
+
|
|
12
|
+
normalized = config.normalize_name(query)
|
|
13
|
+
|
|
14
|
+
exact = config.contexts[normalized]
|
|
15
|
+
return exact if exact
|
|
16
|
+
|
|
17
|
+
absolute_query = File.expand_path(query)
|
|
18
|
+
path_match = config.contexts.values.find { |ctx| ctx["repo_path"] == absolute_query }
|
|
19
|
+
return path_match if path_match
|
|
20
|
+
|
|
21
|
+
fuzzy_match(normalized)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :config
|
|
27
|
+
|
|
28
|
+
def fuzzy_match(normalized_query)
|
|
29
|
+
matches = config.contexts.values.filter do |ctx|
|
|
30
|
+
subsequence?(normalized_query, ctx["name"]) || subsequence?(normalized_query, File.basename(ctx["repo_path"]).downcase)
|
|
31
|
+
end
|
|
32
|
+
return nil if matches.empty?
|
|
33
|
+
|
|
34
|
+
scored = matches.sort_by { |ctx| [score(normalized_query, ctx["name"]), ctx["name"]] }
|
|
35
|
+
best = scored.first
|
|
36
|
+
# If first two scores tie exactly, consider ambiguous.
|
|
37
|
+
return nil if scored[1] && score(normalized_query, scored[1]["name"]) == score(normalized_query, best["name"])
|
|
38
|
+
|
|
39
|
+
best
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def subsequence?(needle, haystack)
|
|
43
|
+
i = 0
|
|
44
|
+
haystack.each_char do |char|
|
|
45
|
+
i += 1 if i < needle.length && needle[i] == char
|
|
46
|
+
end
|
|
47
|
+
i == needle.length
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def score(query, candidate)
|
|
51
|
+
distance = candidate.length - query.length
|
|
52
|
+
starts = candidate.start_with?(query) ? 0 : 1
|
|
53
|
+
[starts, distance]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module DevContext
|
|
6
|
+
class ShellEmitter
|
|
7
|
+
SCRIPT_MARKER = "# DX_SHELL_EVAL".freeze
|
|
8
|
+
|
|
9
|
+
def initialize(context:, remote_name:, auto_create_local_branch:)
|
|
10
|
+
@context = context
|
|
11
|
+
@remote_name = remote_name
|
|
12
|
+
@auto_create_local_branch = auto_create_local_branch
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def activation_script
|
|
16
|
+
repo = Shellwords.escape(context.fetch("repo_path"))
|
|
17
|
+
branch = Shellwords.escape(context.fetch("branch"))
|
|
18
|
+
forced_remote = Shellwords.escape(remote_name)
|
|
19
|
+
auto_create_flag = auto_create_local_branch ? "true" : "false"
|
|
20
|
+
|
|
21
|
+
<<~SH
|
|
22
|
+
#{SCRIPT_MARKER}
|
|
23
|
+
__dx_fail() { echo "$1" 1>&2; return "${2:-1}"; }
|
|
24
|
+
cd #{repo} || __dx_fail "dx: could not cd into #{repo}" 11
|
|
25
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || __dx_fail "dx: not a git repository: #{repo}" 12
|
|
26
|
+
|
|
27
|
+
if git show-ref --verify --quiet "refs/heads/#{branch}"; then
|
|
28
|
+
git checkout #{branch} >/dev/null 2>&1 || __dx_fail "dx: checkout failed for #{branch}" 13
|
|
29
|
+
elif git show-ref --verify --quiet "refs/remotes/origin/#{branch}" && [ "#{auto_create_flag}" = "true" ]; then
|
|
30
|
+
git checkout -t "origin/#{branch}" >/dev/null 2>&1 || __dx_fail "dx: could not create local tracking branch #{branch}" 14
|
|
31
|
+
else
|
|
32
|
+
__dx_fail "dx: branch '#{branch}' was not found locally; set DX_AUTO_CREATE_LOCAL_BRANCH=true and ensure origin/#{branch} exists" 15
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
if [ #{forced_remote.inspect} = "USE-REPO" ]; then
|
|
36
|
+
upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true)"
|
|
37
|
+
if [ -n "$upstream" ]; then
|
|
38
|
+
remote="${upstream%%/*}"
|
|
39
|
+
upstream_branch="${upstream#*/}"
|
|
40
|
+
git pull --ff-only --autostash "$remote" "$upstream_branch" || __dx_fail "dx: pull failed for upstream $upstream (you are already in the target repo for investigation)" 16
|
|
41
|
+
else
|
|
42
|
+
remote_count="$(git remote | wc -l | tr -d ' ')"
|
|
43
|
+
if [ "$remote_count" = "1" ]; then
|
|
44
|
+
remote="$(git remote | head -n1)"
|
|
45
|
+
git pull --ff-only --autostash "$remote" #{branch} || __dx_fail "dx: pull failed from $remote/#{branch} (you are already in the target repo for investigation)" 17
|
|
46
|
+
else
|
|
47
|
+
__dx_fail "dx: no upstream is configured and remote is ambiguous; configure upstream or set DX_GIT_REMOTE_NAME" 18
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
else
|
|
51
|
+
git remote get-url #{forced_remote} >/dev/null 2>&1 || __dx_fail "dx: remote '#{forced_remote}' does not exist in this repo" 19
|
|
52
|
+
git pull --ff-only --autostash #{forced_remote} #{branch} || __dx_fail "dx: pull failed from #{forced_remote}/#{branch} (you are already in the target repo for investigation)" 20
|
|
53
|
+
fi
|
|
54
|
+
SH
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
attr_reader :context, :remote_name, :auto_create_local_branch
|
|
60
|
+
end
|
|
61
|
+
end
|