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.
@@ -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