canopus 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +191 -19
- data/assets/keymaps/emacs.jsonc +5 -0
- data/assets/keymaps/jetbrains.jsonc +5 -0
- data/assets/keymaps/sublime.jsonc +5 -0
- data/assets/keymaps/vscode.jsonc +5 -0
- data/docs/debugging.md +143 -0
- data/docs/distribution.md +40 -9
- data/docs/lsp.md +131 -0
- data/docs/performance.md +18 -0
- data/docs/providers.md +36 -0
- data/docs/release-log.md +12 -0
- data/docs/tasks.md +64 -0
- data/docs/testing.md +49 -0
- data/docs/vim.md +3 -0
- data/exe/canopus +4 -1
- data/lib/canopus/buffer.rb +291 -50
- data/lib/canopus/cli.rb +5 -2
- data/lib/canopus/command.rb +13 -4
- data/lib/canopus/controller.rb +183 -40
- data/lib/canopus/debug/breakpoints.rb +550 -0
- data/lib/canopus/debug/configuration.rb +291 -0
- data/lib/canopus/debug/console.rb +243 -0
- data/lib/canopus/debug/panel.rb +487 -0
- data/lib/canopus/debug/session.rb +354 -0
- data/lib/canopus/decoration.rb +4 -2
- data/lib/canopus/denebola_compat.rb +27 -0
- data/lib/canopus/diagnostics.rb +173 -0
- data/lib/canopus/display_map/overlay_map.rb +6 -2
- data/lib/canopus/editor/snippet_expandable.rb +3 -2
- data/lib/canopus/editor.rb +122 -31
- data/lib/canopus/git/state.rb +100 -0
- data/lib/canopus/language/background_analysis.rb +93 -14
- data/lib/canopus/language/document.rb +70 -34
- data/lib/canopus/language/symbol.rb +1 -0
- data/lib/canopus/language/syntax_worker.rb +61 -9
- data/lib/canopus/language.rb +3 -0
- data/lib/canopus/minimap.rb +201 -0
- data/lib/canopus/patch.rb +3 -1
- data/lib/canopus/plugins/api.rb +4 -2
- data/lib/canopus/plugins/isolated_runtime.rb +64 -7
- data/lib/canopus/plugins/registry.rb +114 -3
- data/lib/canopus/plugins.rb +8 -7
- data/lib/canopus/project.rb +15 -6
- data/lib/canopus/provider.rb +192 -0
- data/lib/canopus/regexp_compat.rb +36 -4
- data/lib/canopus/selection.rb +12 -0
- data/lib/canopus/settings/schema.rb +201 -0
- data/lib/canopus/settings.rb +328 -49
- data/lib/canopus/task/configuration.rb +356 -0
- data/lib/canopus/task/problem_matcher.rb +264 -0
- data/lib/canopus/task/runner.rb +314 -0
- data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
- data/lib/canopus/test_runner/discovery.rb +97 -0
- data/lib/canopus/test_runner/execution.rb +194 -0
- data/lib/canopus/test_runner/minitest.rb +116 -0
- data/lib/canopus/test_runner/rspec.rb +103 -0
- data/lib/canopus/test_runner/safe_walker.rb +37 -0
- data/lib/canopus/test_runner.rb +54 -0
- data/lib/canopus/theme/import.rb +131 -0
- data/lib/canopus/theme.rb +28 -4
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/vim/motionable.rb +1 -1
- data/lib/canopus/vim.rb +10 -16
- data/lib/canopus/workspace/auto_savable.rb +83 -0
- data/lib/canopus/workspace/debug_aware.rb +600 -0
- data/lib/canopus/workspace/edit/plan.rb +2 -2
- data/lib/canopus/workspace/editor_configurable.rb +113 -0
- data/lib/canopus/workspace/file_change_aware.rb +11 -3
- data/lib/canopus/workspace/git_aware.rb +80 -34
- data/lib/canopus/workspace/git_blame.rb +175 -0
- data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
- data/lib/canopus/workspace/git_diff_view.rb +641 -0
- data/lib/canopus/workspace/git_history.rb +171 -0
- data/lib/canopus/workspace/git_remote.rb +267 -0
- data/lib/canopus/workspace/git_staging.rb +583 -0
- data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
- data/lib/canopus/workspace/keymap_aware.rb +66 -0
- data/lib/canopus/workspace/language_aware.rb +3721 -135
- data/lib/canopus/workspace/language_server_configurable.rb +287 -71
- data/lib/canopus/workspace/persistent_undo.rb +169 -0
- data/lib/canopus/workspace/problems_aware.rb +131 -0
- data/lib/canopus/workspace/project_searchable.rb +88 -11
- data/lib/canopus/workspace/project_tree_editable.rb +3 -1
- data/lib/canopus/workspace/recoverable.rb +204 -0
- data/lib/canopus/workspace/session_persistable.rb +85 -13
- data/lib/canopus/workspace/settings_aware.rb +76 -2
- data/lib/canopus/workspace/task_aware.rb +194 -0
- data/lib/canopus/workspace/test_aware.rb +394 -0
- data/lib/canopus/workspace/trust.rb +73 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
- data/lib/canopus/workspace/view.rb +399 -74
- data/lib/canopus/workspace.rb +688 -44
- data/lib/canopus.rb +20 -2
- data/sig/canopus.rbs +138 -31
- data/sig/controller.rbs +10 -3
- data/sig/debug.rbs +121 -0
- data/sig/decoration.rbs +12 -2
- data/sig/diagnostics.rbs +26 -0
- data/sig/minimap.rbs +20 -0
- data/sig/provider.rbs +29 -0
- data/sig/services.rbs +1 -16
- data/sig/task.rbs +92 -0
- data/sig/test_runner.rbs +109 -0
- data/sig/trust.rbs +17 -0
- data/sig/workspace_edits.rbs +2 -2
- data/sig/workspace_services.rbs +234 -2
- data/tools/check_dependencies.rb +21 -2
- data/tools/package.rb +172 -14
- data/tools/package_signature_test.rb +33 -0
- data/tools/package_test.rb +29 -0
- data/tools/sign_package.rb +123 -0
- data/tools/update.rb +189 -0
- data/tools/update_test.rb +71 -0
- metadata +206 -31
- data/lib/canopus/lsp/client.rb +0 -307
- data/lib/canopus/lsp/error.rb +0 -7
- data/lib/canopus/lsp/future/subscription.rb +0 -9
- data/lib/canopus/lsp/future.rb +0 -87
- data/lib/canopus/lsp/protocol.rb +0 -83
- data/lib/canopus/lsp/server_error.rb +0 -13
- data/lib/canopus/lsp/timeout.rb +0 -7
- data/lib/canopus/lsp/transport.rb +0 -123
- data/lib/canopus/lsp.rb +0 -19
- data/lib/canopus/project/search.rb +0 -164
- data/lib/canopus/project/search_worker/cancelled.rb +0 -3
- data/lib/canopus/project/search_worker/runner.rb +0 -9
- data/lib/canopus/project/search_worker.rb +0 -108
- data/lib/canopus/terminal/cell.rb +0 -7
- data/lib/canopus/terminal/grid.rb +0 -321
- data/lib/canopus/terminal/pty.rb +0 -178
- data/lib/canopus/terminal/scrollback.rb +0 -38
- data/lib/canopus/terminal/vt.rb +0 -398
- data/lib/canopus/terminal.rb +0 -13
- data/sig/lsp.rbs +0 -99
- data/sig/terminal.rbs +0 -130
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Workspace::GitHistory
|
|
5
|
+
MAX_COMMITS = 200
|
|
6
|
+
MAX_HISTORY_TREE_ENTRIES = 200_000
|
|
7
|
+
MAX_COMMIT_PATHS = 2_000
|
|
8
|
+
|
|
9
|
+
GitCommitEntry = Data.define(:oid, :parents, :subject, :author, :time, :paths, :graph, :paths_truncated)
|
|
10
|
+
|
|
11
|
+
def git_commit_history(revision: "HEAD", limit: 100)
|
|
12
|
+
state = git_state
|
|
13
|
+
raise Error, "Not a Git repository" unless state
|
|
14
|
+
revision = validate_git_revision(revision)
|
|
15
|
+
unless limit.is_a?(Integer) && limit.between?(1, MAX_COMMITS)
|
|
16
|
+
raise ArgumentError, "history limit must be between 1 and #{MAX_COMMITS}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
state.synchronize { |repository| collect_git_commit_history(repository, revision, limit) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def show_git_commit_history(revision: "HEAD", limit: 100, async: !!@window)
|
|
23
|
+
revision = validate_git_revision(revision)
|
|
24
|
+
if @window && async
|
|
25
|
+
generation = @git_commit_history_generation = (@git_commit_history_generation || 0) + 1
|
|
26
|
+
palette_generation = @palette_generation.to_i
|
|
27
|
+
self.message = "Loading Git history…"
|
|
28
|
+
worker = Thread.new do
|
|
29
|
+
current = Thread.current
|
|
30
|
+
entries = git_commit_history(revision: revision, limit: limit)
|
|
31
|
+
post { finish_git_commit_history(current, generation, palette_generation, entries, nil) } unless @closed
|
|
32
|
+
rescue StandardError => error
|
|
33
|
+
post { finish_git_commit_history(current, generation, palette_generation, nil, error) } unless @closed
|
|
34
|
+
end
|
|
35
|
+
(@git_commit_history_jobs ||= []) << worker
|
|
36
|
+
return worker
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
install_git_commit_history(git_commit_history(revision: revision, limit: limit))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def open_git_commit(entry)
|
|
43
|
+
raise ArgumentError, "expected a Git commit entry" unless entry.is_a?(GitCommitEntry)
|
|
44
|
+
if entry.paths.empty?
|
|
45
|
+
self.message = entry.paths_truncated ? "Changed paths omitted by the history budget" : "Commit has no file changes"
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
return compare_git_revisions(entry.parents.first, entry.oid, path: entry.paths.first) if entry.paths.one?
|
|
49
|
+
|
|
50
|
+
labels = entry.paths.map { |path| scm_display_path(path) }
|
|
51
|
+
self.palette = {kind: :git_commit_paths, query: +"", index: 0, matches: labels,
|
|
52
|
+
all_matches: labels, items: entry.paths, commit: entry}
|
|
53
|
+
update_palette
|
|
54
|
+
@palette
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def close_git
|
|
58
|
+
@git_commit_history_generation = (@git_commit_history_generation || 0) + 1
|
|
59
|
+
jobs = [*@git_commit_history_jobs].compact.uniq.reject { |thread| thread.equal?(Thread.current) }
|
|
60
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.1
|
|
61
|
+
jobs.each do |thread|
|
|
62
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
|
+
break unless remaining.positive?
|
|
64
|
+
thread.join(remaining)
|
|
65
|
+
end
|
|
66
|
+
jobs.each { |thread| thread.kill if thread.alive? }
|
|
67
|
+
jobs.each(&:join)
|
|
68
|
+
@git_commit_history_jobs&.clear
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def collect_git_commit_history(repository, revision, limit)
|
|
75
|
+
resolve_git_revision(repository, revision)
|
|
76
|
+
lanes = []
|
|
77
|
+
tree_entries = 0
|
|
78
|
+
commits = topological_git_commits(repository.each_commit(revision, limit: limit).to_a)
|
|
79
|
+
commits.map do |commit|
|
|
80
|
+
parents = commit.parents.map { |oid| oid.dup.freeze }.freeze
|
|
81
|
+
lane = lanes.index(commit.oid) || lanes.length
|
|
82
|
+
lanes << commit.oid unless lanes.include?(commit.oid)
|
|
83
|
+
graph = lanes.each_index.map { |index| index == lane ? "●" : "│" }.join(" ").freeze
|
|
84
|
+
lanes.delete_at(lane)
|
|
85
|
+
parents.reverse_each { |parent| lanes.insert(lane, parent) unless lanes.include?(parent) }
|
|
86
|
+
|
|
87
|
+
paths, consumed, truncated = git_commit_paths(repository, commit, MAX_HISTORY_TREE_ENTRIES - tree_entries)
|
|
88
|
+
tree_entries += consumed
|
|
89
|
+
signature = commit.signature(role: :author)
|
|
90
|
+
GitCommitEntry.new(commit.oid.dup.freeze, parents,
|
|
91
|
+
safe_git_history_text(commit.message.to_s.lines.first, 200).freeze,
|
|
92
|
+
safe_git_history_text(signature.name, 100).freeze, signature.time,
|
|
93
|
+
paths.freeze, graph, truncated).freeze
|
|
94
|
+
end.freeze
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def topological_git_commits(commits)
|
|
98
|
+
remaining = commits.dup
|
|
99
|
+
ordered = []
|
|
100
|
+
until remaining.empty?
|
|
101
|
+
index = remaining.index do |candidate|
|
|
102
|
+
remaining.none? { |child| child.parents.include?(candidate.oid) }
|
|
103
|
+
end
|
|
104
|
+
raise Error, "Invalid Git commit graph" unless index
|
|
105
|
+
|
|
106
|
+
ordered << remaining.delete_at(index)
|
|
107
|
+
end
|
|
108
|
+
ordered
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def git_commit_paths(repository, commit, remaining)
|
|
112
|
+
return [[], 0, true] unless remaining.positive?
|
|
113
|
+
|
|
114
|
+
current = repository.tree(commit.oid)
|
|
115
|
+
previous = commit.parents.first ? repository.tree(commit.parents.first) : {}
|
|
116
|
+
paths, consumed, truncated = [], 0, false
|
|
117
|
+
current.each do |path, entry|
|
|
118
|
+
consumed += 1
|
|
119
|
+
if consumed > remaining
|
|
120
|
+
truncated = true
|
|
121
|
+
break
|
|
122
|
+
end
|
|
123
|
+
paths << path.dup.freeze unless same_git_tree_entry?(entry, previous[path])
|
|
124
|
+
if paths.length >= MAX_COMMIT_PATHS
|
|
125
|
+
truncated = true
|
|
126
|
+
break
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
unless truncated
|
|
130
|
+
previous.each do |path, entry|
|
|
131
|
+
consumed += 1
|
|
132
|
+
if consumed > remaining
|
|
133
|
+
truncated = true
|
|
134
|
+
break
|
|
135
|
+
end
|
|
136
|
+
paths << path.dup.freeze unless current.key?(path) || same_git_tree_entry?(current[path], entry)
|
|
137
|
+
if paths.length >= MAX_COMMIT_PATHS
|
|
138
|
+
truncated = true
|
|
139
|
+
break
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
[paths.sort_by(&:b), [consumed, remaining].min, truncated]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def git_commit_history_label(entry)
|
|
147
|
+
paths = entry.paths.first(4).map { |path| scm_display_path(path) }.join(", ")
|
|
148
|
+
paths << ", …" if entry.paths.length > 4 || entry.paths_truncated
|
|
149
|
+
suffix = paths.empty? ? "" : " · #{paths}"
|
|
150
|
+
"#{entry.graph} #{entry.oid[0, 8]} #{entry.subject} · #{entry.author}#{suffix}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def git_commit_history_search(entry)
|
|
154
|
+
([entry.oid, entry.subject, entry.author] + entry.paths.map { |path| scm_display_path(path) }).join(" ")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def install_git_commit_history(entries)
|
|
158
|
+
self.palette = {kind: :git_commit_history, query: +"", index: 0,
|
|
159
|
+
matches: entries.map { |entry| git_commit_history_label(entry) }, items: entries,
|
|
160
|
+
search_values: entries.map { |entry| git_commit_history_search(entry) }}
|
|
161
|
+
update_palette
|
|
162
|
+
@palette
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def finish_git_commit_history(worker, generation, palette_generation, entries, error)
|
|
166
|
+
@git_commit_history_jobs&.delete(worker)
|
|
167
|
+
return unless generation == @git_commit_history_generation && palette_generation == @palette_generation.to_i
|
|
168
|
+
error ? self.message = "Git history: #{safe_git_history_text(error.message, 500)}" : install_git_commit_history(entries)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Workspace::GitRemote
|
|
5
|
+
TRANSFER_PHASES = %i[pack remote push].freeze
|
|
6
|
+
GitTransferState = Struct.new(:operation, :remote, :lock, :cancelled, :job, :progress,
|
|
7
|
+
:progress_posted, :prompt_auth, :redactions, :locked_buffers, keyword_init: true) do
|
|
8
|
+
def cancel!
|
|
9
|
+
lock.synchronize { self.cancelled = true }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def cancelled? = lock.synchronize { cancelled }
|
|
13
|
+
end
|
|
14
|
+
private_constant :GitTransferState
|
|
15
|
+
|
|
16
|
+
def git_remotes
|
|
17
|
+
state = git_state
|
|
18
|
+
raise Error, "Not a Git repository" unless state
|
|
19
|
+
state.synchronize { |repository| repository.remotes.transform_keys { |name| name.dup.freeze }.freeze }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch_git(remote = nil, credentials: nil)
|
|
23
|
+
start_git_transfer(:fetch, remote, credentials: credentials)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pull_git(remote = nil, credentials: nil)
|
|
27
|
+
raise Error, "Save or discard all buffer changes before pulling" if @buffers.values.any?(&:dirty?)
|
|
28
|
+
start_git_transfer(:pull, remote, credentials: credentials)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def push_git(remote = nil, credentials: nil)
|
|
32
|
+
start_git_transfer(:push, remote, credentials: credentials)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cancel_git_transfer
|
|
36
|
+
state = (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state }
|
|
37
|
+
return false unless state
|
|
38
|
+
|
|
39
|
+
state.cancel!
|
|
40
|
+
self.message = "Cancelling Git #{state.operation}…"
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def git_transfer_progress
|
|
45
|
+
state = (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state }
|
|
46
|
+
state&.lock&.synchronize { state.progress }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def poll_git_changes(now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
50
|
+
return if @closed
|
|
51
|
+
|
|
52
|
+
super
|
|
53
|
+
poll_git_autofetch(now)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def accept_git_credentials
|
|
58
|
+
prompt = @palette
|
|
59
|
+
raise Error, "No Git credential prompt is open" unless prompt&.dig(:kind) == :git_credentials
|
|
60
|
+
|
|
61
|
+
value = prompt.fetch(:query)
|
|
62
|
+
if prompt[:stage] == :username
|
|
63
|
+
raise Error, "Git username is too long" if value.bytesize > 1_024
|
|
64
|
+
username = value.dup
|
|
65
|
+
value.clear
|
|
66
|
+
self.palette = prompt.merge(stage: username.empty? ? :token : :password, username: username,
|
|
67
|
+
query: +"", secret: true, matches: [])
|
|
68
|
+
return @palette
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
raise Error, "Git credential is too long" if value.bytesize > 4_096
|
|
72
|
+
secret = value.dup
|
|
73
|
+
value.clear
|
|
74
|
+
operation, remote, username = prompt.values_at(:operation, :remote, :username)
|
|
75
|
+
self.palette = nil
|
|
76
|
+
credentials = username.to_s.empty? ? Thuban::Remote::Credentials.bearer(token: secret) :
|
|
77
|
+
Thuban::Remote::Credentials.static(username: username, password: secret)
|
|
78
|
+
start_git_transfer(operation, remote, credentials: credentials, prompt_auth: false,
|
|
79
|
+
redactions: [username, secret].compact)
|
|
80
|
+
ensure
|
|
81
|
+
secret&.clear
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def close_git
|
|
85
|
+
state = (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state }
|
|
86
|
+
if state
|
|
87
|
+
state.cancel!
|
|
88
|
+
state.job&.join unless state.job&.equal?(Thread.current)
|
|
89
|
+
end
|
|
90
|
+
super
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def poll_git_autofetch(now)
|
|
96
|
+
options = @settings["git"]
|
|
97
|
+
return unless options["autofetch"]
|
|
98
|
+
unless @last_git_autofetch
|
|
99
|
+
@last_git_autofetch = now
|
|
100
|
+
return
|
|
101
|
+
end
|
|
102
|
+
return if now - @last_git_autofetch < options["autofetch_interval"]
|
|
103
|
+
return if (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state }
|
|
104
|
+
|
|
105
|
+
@last_git_autofetch = now
|
|
106
|
+
fetch_git
|
|
107
|
+
rescue Error => error
|
|
108
|
+
self.message = "Git autofetch: #{safe_git_history_text(error.message, 200)}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def start_git_transfer(operation, remote, credentials:, prompt_auth: true, redactions: [])
|
|
112
|
+
if operation == :pull && @buffers.values.any?(&:dirty?)
|
|
113
|
+
raise Error, "Save or discard all buffer changes before pulling"
|
|
114
|
+
end
|
|
115
|
+
state = nil
|
|
116
|
+
guard = @git_transfer_guard ||= Mutex.new
|
|
117
|
+
guard.synchronize do
|
|
118
|
+
raise Error, "A Git transfer is already in progress" if @git_transfer_state
|
|
119
|
+
remote = select_git_remote(operation, remote)
|
|
120
|
+
return remote if remote.is_a?(Hash)
|
|
121
|
+
state = GitTransferState.new(operation: operation, remote: remote, lock: Mutex.new,
|
|
122
|
+
cancelled: false, prompt_auth: prompt_auth && credentials.nil?, redactions: redactions.map(&:dup).freeze,
|
|
123
|
+
locked_buffers: operation == :pull ? lock_pull_buffers : [].freeze)
|
|
124
|
+
@git_transfer_state = state
|
|
125
|
+
state.job = Thread.new { run_git_transfer(state, credentials) }
|
|
126
|
+
end
|
|
127
|
+
self.message = "Git #{operation} started"
|
|
128
|
+
state.job
|
|
129
|
+
rescue StandardError
|
|
130
|
+
unlock_pull_buffers(state&.locked_buffers)
|
|
131
|
+
raise
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def select_git_remote(operation, requested)
|
|
135
|
+
remotes = git_remotes
|
|
136
|
+
if requested
|
|
137
|
+
raise Error, "Unknown Git remote" unless requested.is_a?(String) && remotes.key?(requested)
|
|
138
|
+
return requested.dup.freeze
|
|
139
|
+
end
|
|
140
|
+
return "origin".freeze if remotes.key?("origin")
|
|
141
|
+
return remotes.keys.first if remotes.one?
|
|
142
|
+
raise Error, "No Git remotes are configured" if remotes.empty?
|
|
143
|
+
|
|
144
|
+
labels = remotes.keys.map { |name| safe_git_history_text(name, 200) }
|
|
145
|
+
self.palette = {kind: :git_remotes, operation: operation, query: +"", index: 0,
|
|
146
|
+
matches: labels, all_matches: labels, items: remotes.keys}
|
|
147
|
+
update_palette
|
|
148
|
+
@palette
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def run_git_transfer(state, credentials)
|
|
152
|
+
effective = credentials || default_git_credentials(state.remote)
|
|
153
|
+
state.lock.synchronize { state.prompt_auth = false unless effective }
|
|
154
|
+
git_state.synchronize do |repository|
|
|
155
|
+
progress = ->(event) { publish_git_transfer_progress(state, event) }
|
|
156
|
+
cancelled = -> { state.cancelled? }
|
|
157
|
+
case state.operation
|
|
158
|
+
when :fetch
|
|
159
|
+
repository.fetch(state.remote, credentials: effective, cancelled: cancelled, &progress)
|
|
160
|
+
when :pull
|
|
161
|
+
repository.pull(state.remote, credentials: effective, cancelled: cancelled, &progress)
|
|
162
|
+
when :push
|
|
163
|
+
branch = repository.branch
|
|
164
|
+
raise Error, "Cannot push from a detached HEAD" unless branch
|
|
165
|
+
ref = "refs/heads/#{branch}"
|
|
166
|
+
repository.push(state.remote, refspecs: "#{ref}:#{ref}", credentials: effective,
|
|
167
|
+
cancelled: cancelled, &progress)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
post { finish_git_transfer(state, nil) } unless @closed
|
|
171
|
+
rescue StandardError => error
|
|
172
|
+
post { finish_git_transfer(state, error) } unless @closed
|
|
173
|
+
ensure
|
|
174
|
+
(@git_transfer_guard ||= Mutex.new).synchronize do
|
|
175
|
+
@git_transfer_state = nil if @closed && @git_transfer_state.equal?(state)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def default_git_credentials(remote)
|
|
180
|
+
url = git_remotes[remote]
|
|
181
|
+
url&.match?(/\Ahttps?:\/\//i) ? Thuban::Remote::Credentials.helper : nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def publish_git_transfer_progress(state, event)
|
|
185
|
+
phase = TRANSFER_PHASES.include?(event.phase) ? event.phase : :remote
|
|
186
|
+
progress = {operation: state.operation, remote: safe_git_history_text(state.remote, 200), phase: phase,
|
|
187
|
+
current: transfer_number(event.current), total: transfer_number(event.total), bytes: transfer_number(event.bytes)}.freeze
|
|
188
|
+
should_post = state.lock.synchronize do
|
|
189
|
+
state.progress = progress
|
|
190
|
+
next false if state.progress_posted
|
|
191
|
+
state.progress_posted = true
|
|
192
|
+
end
|
|
193
|
+
post { install_git_transfer_progress(state) } if should_post && !@closed
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def transfer_number(value)
|
|
197
|
+
value.is_a?(Integer) && value >= 0 ? value : nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def install_git_transfer_progress(state)
|
|
201
|
+
progress = state.lock.synchronize do
|
|
202
|
+
state.progress_posted = false
|
|
203
|
+
state.progress
|
|
204
|
+
end
|
|
205
|
+
return unless (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state.equal?(state) }
|
|
206
|
+
|
|
207
|
+
detail = progress[:total] ? " #{progress[:current] || 0}/#{progress[:total]}" :
|
|
208
|
+
progress[:bytes] ? " #{progress[:bytes]} bytes" : ""
|
|
209
|
+
self.message = "Git #{state.operation}: #{progress[:phase]}#{detail}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def finish_git_transfer(state, error)
|
|
213
|
+
current = (@git_transfer_guard ||= Mutex.new).synchronize do
|
|
214
|
+
next false unless @git_transfer_state.equal?(state)
|
|
215
|
+
@git_transfer_state = nil
|
|
216
|
+
true
|
|
217
|
+
end
|
|
218
|
+
return unless current
|
|
219
|
+
|
|
220
|
+
unlock_pull_buffers(state.locked_buffers)
|
|
221
|
+
if error.is_a?(Thuban::AuthenticationError) && state.prompt_auth
|
|
222
|
+
return prompt_git_credentials(state.operation, state.remote)
|
|
223
|
+
end
|
|
224
|
+
if error
|
|
225
|
+
self.message = git_transfer_error_message(state, error)
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
invalidate_git
|
|
230
|
+
refresh_files
|
|
231
|
+
refresh_scm if @scm_tree
|
|
232
|
+
if state.operation == :pull
|
|
233
|
+
@buffers.values.uniq.each do |buffer|
|
|
234
|
+
buffer.reload if buffer.path && File.file?(buffer.path) && !buffer.read_only && !buffer.dirty?
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
self.message = "Git #{state.operation} complete"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def prompt_git_credentials(operation, remote)
|
|
241
|
+
self.palette = {kind: :git_credentials, operation: operation, remote: remote,
|
|
242
|
+
stage: :username, query: +"", index: 0, matches: [], secret: false}
|
|
243
|
+
self.message = "Authentication required; enter a username, or leave blank for a bearer token"
|
|
244
|
+
@palette
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def git_transfer_error_message(state, error)
|
|
248
|
+
return "Git #{state.operation} cancelled" if error.is_a?(Thuban::Cancelled)
|
|
249
|
+
return "Git #{state.operation} authentication failed" if error.is_a?(Thuban::AuthenticationError)
|
|
250
|
+
return "Git #{state.operation} failed" if error.is_a?(Thuban::TransportError)
|
|
251
|
+
|
|
252
|
+
text = safe_git_history_text(error.message, 500)
|
|
253
|
+
state.redactions.each { |secret| text = text.gsub(secret, "[REDACTED]") unless secret.empty? }
|
|
254
|
+
"Git #{state.operation}: #{text}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def lock_pull_buffers
|
|
258
|
+
buffers = @buffers.values.uniq.select { |buffer| buffer.path && !buffer.read_only }
|
|
259
|
+
buffers.each { |buffer| buffer.instance_variable_set(:@read_only, true) }
|
|
260
|
+
buffers.freeze
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def unlock_pull_buffers(buffers)
|
|
264
|
+
buffers&.each { |buffer| buffer.instance_variable_set(:@read_only, false) }
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|