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.
@@ -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
@@ -0,0 +1,5 @@
1
+ require_relative 'workspaces/config'
2
+ require_relative 'workspaces/state_store'
3
+ require_relative 'workspaces/port_allocator'
4
+ require_relative 'workspaces/backend'
5
+ require_relative 'workspaces/cli'
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: []