workspaces 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/DESIGN.md +67 -0
- data/LICENSE +21 -0
- data/README.md +146 -0
- data/exe/workspaces +5 -0
- data/lib/workspaces/backend.rb +108 -0
- data/lib/workspaces/cli.rb +189 -0
- data/lib/workspaces/command_runner.rb +49 -0
- data/lib/workspaces/config.rb +116 -0
- data/lib/workspaces/coordinator.rb +203 -0
- data/lib/workspaces/dashboard/app.js +132 -0
- data/lib/workspaces/dashboard/checkout_authentication_error.html.erb +22 -0
- data/lib/workspaces/dashboard/environment.js +120 -0
- data/lib/workspaces/dashboard/pull_request.html.erb +37 -0
- data/lib/workspaces/dashboard/styles.css +461 -0
- data/lib/workspaces/dashboard/template.html.erb +82 -0
- data/lib/workspaces/dashboard_view.rb +96 -0
- data/lib/workspaces/environment_overrides.rb +177 -0
- data/lib/workspaces/front_door.rb +275 -0
- data/lib/workspaces/paths.rb +63 -0
- data/lib/workspaces/port_allocator.rb +35 -0
- data/lib/workspaces/presentation.rb +68 -0
- data/lib/workspaces/recipe.rb +135 -0
- data/lib/workspaces/registry.rb +55 -0
- data/lib/workspaces/state_store.rb +84 -0
- data/lib/workspaces/tee_log.rb +39 -0
- data/lib/workspaces/tls.rb +112 -0
- data/lib/workspaces/version.rb +3 -0
- data/lib/workspaces/workflow.rb +78 -0
- data/lib/workspaces/worktree.rb +218 -0
- data/lib/workspaces.rb +5 -0
- metadata +121 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'time'
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
|
|
8
|
+
module Workspaces
|
|
9
|
+
class Worktree
|
|
10
|
+
class CommandFailedError < StandardError; end
|
|
11
|
+
class AuthenticationError < CommandFailedError; end
|
|
12
|
+
AUTH_FAILURE = Regexp.union(/could not read (?:Username|Password)|terminal prompts disabled|Authentication failed/i,
|
|
13
|
+
/Permission denied \(publickey\)|gh auth login|HTTP 401|HTTP 403/i)
|
|
14
|
+
METADATA = '.workspace.json'.freeze
|
|
15
|
+
PR_METADATA = '.related-pr'.freeze
|
|
16
|
+
attr_reader :id, :path
|
|
17
|
+
|
|
18
|
+
def self.create(branch: nil, new_branch: nil, from: nil, **options)
|
|
19
|
+
number = options[:pr]
|
|
20
|
+
unless [branch, number, new_branch].compact.length == 1
|
|
21
|
+
raise ArgumentError, 'Choose exactly one of --branch, --pr, or --new-branch'
|
|
22
|
+
end
|
|
23
|
+
raise ArgumentError, '--from requires --new-branch' if from && !new_branch
|
|
24
|
+
|
|
25
|
+
label = new_branch || branch || "pr-#{number}"
|
|
26
|
+
slug = label.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')[0, 30].to_s.sub(/-+\z/, '')
|
|
27
|
+
workspace = new("#{slug.empty? ? 'workspace' : slug}-#{SecureRandom.hex(4)}")
|
|
28
|
+
workspace.create!(branch: branch, number: number, new_branch: new_branch, from: from)
|
|
29
|
+
workspace
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(id)
|
|
33
|
+
@id = Config.validate_id!(id.to_s)
|
|
34
|
+
@path = Config.worktrees_dir(create: false).join(@id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def create!(branch:, number:, new_branch:, from:)
|
|
38
|
+
Config.preview_host(id)
|
|
39
|
+
raise ArgumentError, 'Workspace directory already exists' if path.exist? || path.symlink?
|
|
40
|
+
|
|
41
|
+
source = number ? resolve_pr(number) : { 'kind' => 'branch', 'ref' => branch || from || 'HEAD' }
|
|
42
|
+
target_branch = new_branch || "workspaces/#{id}"
|
|
43
|
+
add_worktree!(target_branch, resolve_commit(source.fetch('ref')))
|
|
44
|
+
initialize_worktree!(source, target_branch)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def exists?
|
|
48
|
+
return false unless ownership_files?
|
|
49
|
+
|
|
50
|
+
owned_metadata? && owned_repository?
|
|
51
|
+
rescue JSON::ParserError, CommandFailedError, Errno::ENOENT
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def require_owned!
|
|
56
|
+
raise ArgumentError, "Unknown managed workspace: #{id}" unless exists?
|
|
57
|
+
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def metadata
|
|
62
|
+
JSON.parse(path.join(METADATA).read)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def describe
|
|
66
|
+
require_owned!
|
|
67
|
+
metadata.merge('path' => path.to_s, 'branch' => git!('branch', '--show-current', cwd: path),
|
|
68
|
+
'head' => git!('rev-parse', 'HEAD', cwd: path),
|
|
69
|
+
'related_pr' => related_pr, 'dashboard_url' => Config.dashboard_url(id),
|
|
70
|
+
'preview_url' => Config.preview_url(id))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def related_pr
|
|
74
|
+
return nil unless path.join(PR_METADATA).file? && !path.join(PR_METADATA).symlink?
|
|
75
|
+
|
|
76
|
+
JSON.parse(path.join(PR_METADATA).read)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def dirty?
|
|
80
|
+
require_owned!
|
|
81
|
+
!git!('status', '--porcelain', '--untracked-files=all', cwd: path).empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def remove!(force: false)
|
|
85
|
+
require_owned!
|
|
86
|
+
raise ArgumentError, 'Workspace has uncommitted/untracked work; use --force to discard it' if !force && dirty?
|
|
87
|
+
|
|
88
|
+
args = %w(worktree remove)
|
|
89
|
+
args << '--force' if force
|
|
90
|
+
git!(*args, path.to_s)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def git!(*args, cwd: Config.repo_root)
|
|
94
|
+
output, status = Open3.capture2e(git_environment, 'git', *args, chdir: cwd.to_s)
|
|
95
|
+
raise_authentication_error!(output) unless status.success?
|
|
96
|
+
raise CommandFailedError, "git #{args.join(' ')} failed:\n#{output}" unless status.success?
|
|
97
|
+
|
|
98
|
+
output.strip
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def git_environment
|
|
104
|
+
{ 'GIT_TERMINAL_PROMPT' => '0', 'GIT_ASKPASS' => '/bin/false', 'SSH_ASKPASS' => '/bin/false',
|
|
105
|
+
'GCM_INTERACTIVE' => 'Never', 'GIT_SSH_COMMAND' => noninteractive_ssh }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def noninteractive_ssh
|
|
109
|
+
command = ENV.fetch('GIT_SSH_COMMAND', 'ssh')
|
|
110
|
+
"#{command} -o BatchMode=yes -o ConnectTimeout=15"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def raise_authentication_error!(output)
|
|
114
|
+
return unless output.match?(AUTH_FAILURE)
|
|
115
|
+
|
|
116
|
+
raise AuthenticationError,
|
|
117
|
+
'GitHub authentication failed. Run gh auth status and ' \
|
|
118
|
+
'gh auth setup-git --hostname github.com as the service user.'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def add_worktree!(target_branch, sha)
|
|
122
|
+
git!('check-ref-format', '--branch', target_branch)
|
|
123
|
+
raise ArgumentError, 'Workspace branch must not start with a dash' if target_branch.start_with?('-')
|
|
124
|
+
|
|
125
|
+
Config.worktrees_dir.mkpath
|
|
126
|
+
git!('-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', target_branch, path.to_s, sha)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def initialize_worktree!(source, target_branch)
|
|
130
|
+
reject_tracked_metadata!
|
|
131
|
+
exclude_metadata!
|
|
132
|
+
write_metadata!(source, target_branch)
|
|
133
|
+
rescue StandardError
|
|
134
|
+
git!('worktree', 'remove', '--force', path.to_s)
|
|
135
|
+
raise
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def write_metadata!(source, target_branch)
|
|
139
|
+
data = { 'version' => 1, 'id' => id, 'branch' => target_branch, 'source' => source,
|
|
140
|
+
'created_at' => Time.now.utc.iso8601, 'repository_path' => Config.repo_root.realpath.to_s }
|
|
141
|
+
File.write(path.join(METADATA), JSON.pretty_generate(data), perm: 0o600)
|
|
142
|
+
return unless source['kind'] == 'pr'
|
|
143
|
+
|
|
144
|
+
File.write(path.join(PR_METADATA), JSON.pretty_generate(source.slice('repository', 'number')),
|
|
145
|
+
perm: 0o600)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ownership_files?
|
|
149
|
+
!path.symlink? && path.join('.git').file? && path.join(METADATA).file? && !path.join(METADATA).symlink?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def owned_metadata?
|
|
153
|
+
metadata['id'] == id && metadata['repository_path'] == Config.repo_root.realpath.to_s
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def owned_repository?
|
|
157
|
+
path.realpath.dirname == Config.worktrees_dir.realpath &&
|
|
158
|
+
git!('rev-parse', '--path-format=absolute', '--git-common-dir', cwd: path) ==
|
|
159
|
+
git!('rev-parse', '--path-format=absolute', '--git-common-dir')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def resolve_pr(number)
|
|
163
|
+
raise ArgumentError, 'PR number must be a positive integer' unless number.to_s.match?(/\A[1-9][0-9]*\z/)
|
|
164
|
+
|
|
165
|
+
output, status = Open3.capture2e('gh', 'pr', 'view', number.to_s, '--repo', github_repo,
|
|
166
|
+
'--json', 'headRefName,headRefOid', chdir: Config.repo_root.to_s)
|
|
167
|
+
raise_authentication_error!(output) unless status.success?
|
|
168
|
+
raise CommandFailedError, "Unable to resolve PR ##{number}: #{output}" unless status.success?
|
|
169
|
+
|
|
170
|
+
pr_source(number, output)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def pr_source(number, output)
|
|
174
|
+
info = JSON.parse(output)
|
|
175
|
+
sha = info.fetch('headRefOid')
|
|
176
|
+
raise CommandFailedError, 'Invalid PR commit returned by GitHub' unless sha.match?(/\A[0-9a-f]{40,64}\z/)
|
|
177
|
+
|
|
178
|
+
git!('fetch', 'origin', "pull/#{number}/head")
|
|
179
|
+
{ 'kind' => 'pr', 'repository' => github_repo, 'number' => number.to_i,
|
|
180
|
+
'branch' => info.fetch('headRefName'), 'ref' => sha }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def github_repo
|
|
184
|
+
return ENV.fetch('WORKSPACES_GITHUB_REPO') if ENV['WORKSPACES_GITHUB_REPO']
|
|
185
|
+
|
|
186
|
+
remote = git!('remote', 'get-url', 'origin')
|
|
187
|
+
match = %r{github\.com[:/]([^/]+/[^/]+?)(?:\.git)?\z}.match(remote)
|
|
188
|
+
raise ArgumentError, 'Set WORKSPACES_GITHUB_REPO for the GitHub PR shortcut' unless match
|
|
189
|
+
|
|
190
|
+
match[1]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def resolve_commit(ref)
|
|
194
|
+
raise ArgumentError, 'Invalid starting ref' if ref.to_s.empty? || ref.start_with?('-')
|
|
195
|
+
|
|
196
|
+
git!('rev-parse', '--verify', '--end-of-options', "#{ref}^{commit}")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def reject_tracked_metadata!
|
|
200
|
+
tracked = git!('ls-files', '--', METADATA, PR_METADATA, cwd: path)
|
|
201
|
+
raise ArgumentError, 'Source tracks reserved workspace metadata files' unless tracked.empty?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def exclude_metadata!
|
|
205
|
+
common = Pathname(git!('rev-parse', '--path-format=absolute', '--git-common-dir'))
|
|
206
|
+
exclude = common.join('info/exclude')
|
|
207
|
+
exclude.dirname.mkpath
|
|
208
|
+
File.open(exclude, File::RDWR | File::CREAT, 0o644) do |file|
|
|
209
|
+
file.flock(File::LOCK_EX)
|
|
210
|
+
existing = file.read.lines.map(&:strip)
|
|
211
|
+
file.seek(0, IO::SEEK_END)
|
|
212
|
+
["/#{METADATA}", "/#{PR_METADATA}"].each do |pattern|
|
|
213
|
+
file.puts "\n#{pattern}" unless existing.include?(pattern)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
data/lib/workspaces.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: workspaces
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Workspaces contributors
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rack
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rackup
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.2'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.2'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: puma
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '6'
|
|
48
|
+
- - "<"
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '9'
|
|
51
|
+
type: :runtime
|
|
52
|
+
prerelease: false
|
|
53
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '6'
|
|
58
|
+
- - "<"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '9'
|
|
61
|
+
description:
|
|
62
|
+
email:
|
|
63
|
+
executables:
|
|
64
|
+
- workspaces
|
|
65
|
+
extensions: []
|
|
66
|
+
extra_rdoc_files: []
|
|
67
|
+
files:
|
|
68
|
+
- DESIGN.md
|
|
69
|
+
- LICENSE
|
|
70
|
+
- README.md
|
|
71
|
+
- exe/workspaces
|
|
72
|
+
- lib/workspaces.rb
|
|
73
|
+
- lib/workspaces/backend.rb
|
|
74
|
+
- lib/workspaces/cli.rb
|
|
75
|
+
- lib/workspaces/command_runner.rb
|
|
76
|
+
- lib/workspaces/config.rb
|
|
77
|
+
- lib/workspaces/coordinator.rb
|
|
78
|
+
- lib/workspaces/dashboard/app.js
|
|
79
|
+
- lib/workspaces/dashboard/checkout_authentication_error.html.erb
|
|
80
|
+
- lib/workspaces/dashboard/environment.js
|
|
81
|
+
- lib/workspaces/dashboard/pull_request.html.erb
|
|
82
|
+
- lib/workspaces/dashboard/styles.css
|
|
83
|
+
- lib/workspaces/dashboard/template.html.erb
|
|
84
|
+
- lib/workspaces/dashboard_view.rb
|
|
85
|
+
- lib/workspaces/environment_overrides.rb
|
|
86
|
+
- lib/workspaces/front_door.rb
|
|
87
|
+
- lib/workspaces/paths.rb
|
|
88
|
+
- lib/workspaces/port_allocator.rb
|
|
89
|
+
- lib/workspaces/presentation.rb
|
|
90
|
+
- lib/workspaces/recipe.rb
|
|
91
|
+
- lib/workspaces/registry.rb
|
|
92
|
+
- lib/workspaces/state_store.rb
|
|
93
|
+
- lib/workspaces/tee_log.rb
|
|
94
|
+
- lib/workspaces/tls.rb
|
|
95
|
+
- lib/workspaces/version.rb
|
|
96
|
+
- lib/workspaces/workflow.rb
|
|
97
|
+
- lib/workspaces/worktree.rb
|
|
98
|
+
homepage:
|
|
99
|
+
licenses:
|
|
100
|
+
- MIT
|
|
101
|
+
metadata: {}
|
|
102
|
+
post_install_message:
|
|
103
|
+
rdoc_options: []
|
|
104
|
+
require_paths:
|
|
105
|
+
- lib
|
|
106
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '3.3'
|
|
111
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '0'
|
|
116
|
+
requirements: []
|
|
117
|
+
rubygems_version: 3.5.22
|
|
118
|
+
signing_key:
|
|
119
|
+
specification_version: 4
|
|
120
|
+
summary: Managed Git worktrees and browser previews for development
|
|
121
|
+
test_files: []
|