workspaces 0.1.1 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e22bcc91e0c7874f565156a5071aa07dae75e52eda5c9b5555f8d4a878f39aec
4
- data.tar.gz: c72328bf47eb7aa1573221224cdc28f4f1e5f24e9539bf23adf8bfc970da7047
3
+ metadata.gz: 501f96e0f82b4dc9f30ec6682feb64329301b3ee888561e84e65941cff084ae7
4
+ data.tar.gz: 4ae2d5e24099d7638b349a332daaadf89813a96e37996c4f03c41c7baf2f7636
5
5
  SHA512:
6
- metadata.gz: 58edd2f1a515d3ee18a69bfe2fe600ef2b26799689e35d94b57ab87251f634864a9e8a7d12415bdf1c5608dc461480dc38caa29c9f7db4305f62685ce739ac3e
7
- data.tar.gz: d0cd9666cbdb265c23719d2de5aec54a6215b6db2f986ecbe061c27aae6578de5a8145a81669a382322ebc2618d3481edb947599d9edff7d76a68193b247e5c1
6
+ metadata.gz: 67865a47386639bfcff1218218bcf8f055c0b563d295d355d9667e1d8410da7a4981def707bc0f219f4179215b4839a689ec36bfa8bba90738ea92d7771b8ad7
7
+ data.tar.gz: 4be62e33d0f29d0c19751b61bd3b9d0dab8cf334314ce62bc5bcfe5f59569235c960b10647cf5bb7060705068db6c1489052b59a97b543c662750f66df3e6f4d
data/DESIGN.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Workspace list/create screen and per-workspace setup/status screen. Workspace identity is independent of PR provenance.
6
6
 
7
- GET is read-only. All creation and lifecycle controls submit same-origin POST forms. No source-reset or cleanup button exists. Workspace IDs, branches and filesystem paths wrap on narrow screens. Native labelled inputs and buttons are keyboard accessible; polling announces status without replacing form focus or making the entire log an ARIA live region.
7
+ GET is read-only. All creation and lifecycle controls submit same-origin POST forms. Workspace IDs, branches and filesystem paths wrap on narrow screens. Native labelled inputs and buttons are keyboard accessible; polling announces status without replacing form focus or making the entire log an ARIA live region.
8
8
 
9
9
  ## Visual direction
10
10
 
@@ -41,9 +41,11 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
41
41
  ## Component primitives
42
42
 
43
43
  - **Creation form**: labelled branch, PR, and new-branch inputs with a primary create action
44
+ - **Workspace inventory**: one row per managed workspace with a status distinct from backend running state, creation and last startup times, and a link to its detail page. Unknown timestamps are shown as unavailable, not inferred from recent setup.
44
45
  - **Status pill**: current workspace status as text
45
46
  - **Setup progress**: names and ordered states come from repository run steps; failures are highlighted. Restart shows the background launch and later readiness commands.
46
47
  - **Actions by state**: idle/stopped offers Start; active operations show progress without lifecycle buttons; failure offers Retry; ready offers Open workspace plus rebuild/restart/stop.
48
+ - **PR source update**: a secondary Update from PR form only for PR-sourced workspaces; it fetches the latest PR head, fast-forwards the checkout, and preserves local edits without rebuilding or restarting. A persistent result message explains when rebuild is needed or when a retained stash needs manual recovery.
47
49
  - **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
48
50
  - **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
49
51
  - **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
@@ -53,6 +55,8 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
53
55
  - **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
54
56
  - **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
55
57
  - **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
58
+ - **Danger Zone**: isolated section on the detail page with explicit loss-of-uncommitted-and-untracked-work copy, an exact-workspace-ID confirmation field, and a destructive action. Deletion remains possible without JavaScript, is disabled while setup is busy, and never appears as a list-row action.
59
+ - **Workspace favicon**: a small two-pane workspace mark served by the dashboard host. Use `--accent` blue for localhost and `--warning` gold for hosted origins so browser tabs signal the environment without relying on page text. The icon contains no workspace-specific data.
56
60
 
57
61
  ## Accessibility constraints
58
62
 
data/README.md CHANGED
@@ -67,6 +67,25 @@ provides `WORKSPACE_RUN_ID`). The runner also supplies `WORKSPACE_URL` and
67
67
  `WORKSPACE_HOST` for applications that need browser-facing URLs and host settings.
68
68
  `serve` does **not** start workspace backends; use `start` or `restart` for those.
69
69
 
70
+ The `/workspaces` dashboard lists each managed workspace's setup status, whether its
71
+ backend is running, creation time, and last backend startup. On a workspace's detail
72
+ page, the Danger Zone lets an operator delete that one workspace after typing its
73
+ exact ID. Deletion stops the backend and force-removes the worktree, including
74
+ uncommitted and untracked files; the branch and committed changes are preserved.
75
+ Busy workspaces cannot be removed until their current operation finishes. This
76
+ action is protected by the dashboard's same-origin POST check, but that check is
77
+ not authentication—keep hosted deployments behind VPN or SSO.
78
+
79
+ The dashboard favicon is blue for localhost and gold for hosted origins, helping
80
+ distinguish local and hosted tabs.
81
+
82
+ PR workspaces also offer **Update from PR** on their detail page. It fetches the
83
+ latest PR head and fast-forwards the workspace branch only when the new head is
84
+ a descendant of its current commit. Local tracked and untracked changes are
85
+ temporarily stashed and restored (including staged state). On a restore conflict,
86
+ the stash is retained for manual recovery. Updating the checkout does not run
87
+ setup or restart the already-running backend; use Rebuild & restart afterward.
88
+
70
89
  The front door is not an authentication boundary. Its host and same-origin checks
71
90
  protect routing and POST actions, but a publicly reachable instance still needs
72
91
  access control such as VPN or SSO. Do not expose workspace backend ports directly.
@@ -137,16 +137,7 @@ module Workspaces
137
137
  end
138
138
 
139
139
  def remove(id, options)
140
- registry.with_lock(id) do |workspace|
141
- if !options[:force] && workspace.dirty?
142
- raise ArgumentError,
143
- 'Workspace has uncommitted/untracked work; use --force to discard it'
144
- end
145
-
146
- Backend.new(id).stop!
147
- workspace.remove!(force: options[:force])
148
- StateStore.new(Config.state_file).delete(id)
149
- end
140
+ registry.remove(id, force: options[:force])
150
141
  emit({ 'id' => id, 'removed' => true, 'branch_preserved' => true }, options)
151
142
  end
152
143
 
@@ -60,6 +60,17 @@ module Workspaces
60
60
  data.merge('presentation' => Presentation.new(data).to_h)
61
61
  end
62
62
 
63
+ def inventory
64
+ @registry.list.map do |workspace|
65
+ id = workspace.fetch('id')
66
+ entry = @state.get(id) || {}
67
+ busy = locked?(id)
68
+ workspace.merge('status' => resolve_status(id, entry.fetch('status', 'idle'), busy),
69
+ 'running' => !!Backend.new(id).running?, 'active' => busy,
70
+ 'started_at' => entry['started_at'])
71
+ end
72
+ end
73
+
63
74
  private
64
75
 
65
76
  def resolve_status(id, status, busy)
@@ -74,7 +85,9 @@ module Workspaces
74
85
  {
75
86
  'workspace_id' => id, 'status' => status, 'active' => busy,
76
87
  'message' => status == entry['status'] ? entry['message'] : status.capitalize,
77
- 'prepare_started_at' => entry['prepare_started_at'], 'updated_at' => entry['updated_at'],
88
+ 'prepare_started_at' => entry['prepare_started_at'], 'started_at' => entry['started_at'],
89
+ 'running' => !!Backend.new(id).running?, 'updated_at' => entry['updated_at'],
90
+ 'source_update_message' => entry['source_update_message'],
78
91
  'completed_at' => entry['completed_at'], 'last_error' => entry['last_error'],
79
92
  'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
80
93
  'steps' => entry.fetch('steps', []),
@@ -7,7 +7,7 @@
7
7
  const selectedLog = new URLSearchParams(window.location.search).get('log') === 'backend'
8
8
  ? 'backend'
9
9
  : 'setup';
10
- const allowedActions = ['start', 'prepare', 'restart', 'stop'];
10
+ const allowedActions = ['start', 'prepare', 'restart', 'stop', 'update-pr'];
11
11
  let busy = root.dataset.busy === 'true';
12
12
  let started = Date.parse(root.dataset.started);
13
13
  let completed = Date.parse(root.dataset.completed);
@@ -18,6 +18,9 @@
18
18
  });
19
19
  if (window.WorkspaceEnvironment)
20
20
  window.WorkspaceEnvironment.toggleBusy(nextBusy);
21
+ node('remove-form').querySelectorAll('input, button').forEach((control) => {
22
+ control.disabled = nextBusy;
23
+ });
21
24
  }
22
25
 
23
26
  function renderActions(actions) {
@@ -75,6 +78,7 @@
75
78
  completed = Date.parse(data.completed_at);
76
79
  renderStatus(view);
77
80
  node('message').textContent = data.message || '';
81
+ node('source-update-message').textContent = data.source_update_message || '';
78
82
  node('progress').replaceChildren(...view.steps.map(renderStep));
79
83
  renderActions(view.actions);
80
84
  updateLog(data);
@@ -122,7 +126,8 @@
122
126
  }
123
127
 
124
128
  root.addEventListener('submit', (event) => {
125
- if (event.target.id !== 'environment-form') toggleBusyState(true);
129
+ if (event.target.id !== 'environment-form' && event.target.id !== 'remove-form')
130
+ toggleBusyState(true);
126
131
  node('connection').textContent = 'Submitting…';
127
132
  });
128
133
 
@@ -312,6 +312,19 @@ fieldset:disabled {
312
312
  border-color: var(--border);
313
313
  }
314
314
 
315
+ .button--danger {
316
+ background: var(--danger);
317
+ color: var(--bg);
318
+ }
319
+
320
+ .button:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
321
+
322
+ .danger-zone { border-color: var(--danger); }
323
+ .danger-zone h2 { margin-block-start: 0; color: var(--danger); }
324
+ .danger-zone form { display: grid; justify-items: start; gap: var(--space-3); }
325
+ .workspace-list { list-style: none; padding: 0; display: grid; gap: var(--space-3); }
326
+ .workspace-list__item { display: grid; gap: var(--space-1); padding-block: var(--space-3); border-block-end: 1px solid var(--border); overflow-wrap: anywhere; }
327
+
315
328
  .button--success {
316
329
  background: color-mix(in oklab, var(--success) 22%, transparent);
317
330
  border-color: color-mix(in oklab, var(--success) 55%, var(--border));
@@ -22,6 +22,7 @@
22
22
  <div id="actions" class="workspace-actions"><% @presentation['actions'].each do |action| %><%= action_form(action) %><% end %></div>
23
23
  </div>
24
24
  <p id="connection" role="status"></p>
25
+ <p id="source-update-message" role="status"><%= field('source_update_message') %></p>
25
26
  </section>
26
27
  <section class="log-panel" aria-labelledby="log-title">
27
28
  <div class="log-header"><h2 id="log-title">Live <%= field('log_source') == 'backend' ? 'backend' : 'setup' %> log</h2><time id="updated"><%= field('updated_at') %></time></div>
@@ -48,6 +49,16 @@
48
49
  <p class="muted" id="environment-status" role="status" aria-live="polite"></p>
49
50
  </form>
50
51
  </section>
51
- <details class="status-panel"><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd></dl></details>
52
+ <details class="status-panel"><summary>Workspace details</summary><dl><dt>Preview URL</dt><dd><a class="detail-link" href="<%= escape(Config.preview_url(@id)) %>" target="_blank" rel="noopener noreferrer"><%= escape(Config.preview_url(@id)) %></a> <span class="muted">Available when ready</span></dd><dt>Branch</dt><dd><%= field('branch') %></dd><dt>Path</dt><dd><code><%= field('path') %></code></dd><dt>Related PR</dt><dd><%= escape(@snapshot.dig('related_pr', 'number') || 'None') %></dd><dt>Created</dt><dd><%= escape(@snapshot['created_at'] || 'Unavailable') %></dd><dt>Last startup</dt><dd><%= escape(@snapshot['started_at'] || 'Never') %></dd></dl></details>
53
+ <section class="status-panel danger-zone" aria-labelledby="danger-heading">
54
+ <h2 id="danger-heading">Danger Zone</h2>
55
+ <p>Deleting this workspace stops its server and removes its worktree. <strong>Discard uncommitted and untracked changes</strong> permanently. The branch and committed work are preserved.</p>
56
+ <form method="post" action="/workspaces/<%= @id %>/remove" id="remove-form">
57
+ <label for="confirm-id">Type <code><%= @id %></code> to confirm deletion</label>
58
+ <input id="confirm-id" name="confirm_id" required autocomplete="off" spellcheck="false" aria-describedby="remove-warning" <%= 'disabled' if @presentation['busy'] %>>
59
+ <p class="muted" id="remove-warning">This cannot restore uncommitted or untracked files.</p>
60
+ <button class="button button--danger" <%= 'disabled' if @presentation['busy'] %>>Delete workspace</button>
61
+ </form>
62
+ </section>
52
63
  <noscript><a href="/workspaces/<%= @id %>">Refresh status</a></noscript>
53
64
  </main>
@@ -21,8 +21,8 @@ module Workspaces
21
21
  <p><label>Start new branch from <input name="from" placeholder="HEAD"></label></p>
22
22
  <button class="button button--primary">Create workspace</button>
23
23
  </form></section>
24
- <section class="status-panel"><h2>Managed workspaces</h2><ul>
25
- #{workspaces.map { |workspace| "<li><a href=\"/workspaces/#{view.escape(workspace.fetch('id'))}\">#{view.escape(workspace.fetch('id'))}</a> — #{view.escape(workspace['branch'])}</li>" }.join}
24
+ <section class="status-panel"><h2>Managed workspaces</h2><ul class="workspace-list">
25
+ #{workspaces.map { |workspace| view.inventory_item(workspace) }.join}
26
26
  </ul></section></main>
27
27
  HTML
28
28
  end
@@ -35,6 +35,20 @@ module Workspaces
35
35
  new({}).render_checkout_authentication_error(path)
36
36
  end
37
37
 
38
+ def self.favicon
39
+ local = Config.public_uri.host.match?(/(?:\A|\.)localhost\z/i)
40
+ color = local ? '#4f8dff' : '#f1bb53'
41
+ <<~SVG
42
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Workspaces">
43
+ <rect x="3" y="3" width="58" height="58" rx="13" fill="#0f1524"/>
44
+ <rect x="11" y="13" width="42" height="38" rx="5" fill="none" stroke="#{color}" stroke-width="4"/>
45
+ <path d="M11 25h42M32 25v26" fill="none" stroke="#{color}" stroke-width="4"/>
46
+ <circle cx="18" cy="19" r="2" fill="#{color}"/>
47
+ <circle cx="25" cy="19" r="2" fill="#{color}"/>
48
+ </svg>
49
+ SVG
50
+ end
51
+
38
52
  def initialize(snapshot)
39
53
  @snapshot = snapshot
40
54
  @presentation = Presentation.new(snapshot).to_h
@@ -66,6 +80,7 @@ module Workspaces
66
80
  File.read(File.join(__dir__, 'dashboard/environment.js'), encoding: 'UTF-8')
67
81
  '<!doctype html><html lang="en"><head><meta charset="utf-8">' \
68
82
  '<meta name="viewport" content="width=device-width, initial-scale=1">' \
83
+ '<link rel="icon" type="image/svg+xml" href="/favicon.svg">' \
69
84
  "<title>#{escape(title)}</title><style>#{css}</style></head>" \
70
85
  "<body>#{body}<script>#{script}</script></body></html>"
71
86
  end
@@ -74,6 +89,20 @@ module Workspaces
74
89
  ERB::Util.html_escape(value.to_s)
75
90
  end
76
91
 
92
+ def inventory_item(workspace)
93
+ id = escape(Config.validate_id!(workspace.fetch('id')))
94
+ running = workspace['running'] ? 'Running' : 'Not running'
95
+ <<~HTML
96
+ <li class="workspace-list__item">
97
+ <a href="/workspaces/#{id}">#{id}</a>
98
+ <span class="muted">#{escape(workspace['branch'])}</span>
99
+ <span>#{running} · #{escape(workspace['status'] || 'idle')}#{workspace['active'] ? ' (busy)' : ''}</span>
100
+ <span class="muted">Created <time datetime="#{escape(workspace['created_at'])}">#{escape(workspace['created_at'] || 'Unavailable')}</time></span>
101
+ <span class="muted">Last startup <time datetime="#{escape(workspace['started_at'])}">#{escape(workspace['started_at'] || 'Never')}</time></span>
102
+ </li>
103
+ HTML
104
+ end
105
+
77
106
  private
78
107
 
79
108
  def field(name)
@@ -54,8 +54,9 @@ module Workspaces
54
54
  end
55
55
 
56
56
  def editable_values(defaults)
57
- load_file(file_path)
58
- defaults.merge(load_file(editable_file_path).reject { |name, _| sensitive_name?(name) })
57
+ legacy = load_file(file_path).slice(*defaults.keys)
58
+ editable = load_file(editable_file_path).reject { |name, _| sensitive_name?(name) }
59
+ defaults.merge(legacy).merge(editable)
59
60
  end
60
61
 
61
62
  def replace_editable(text, defaults:)
@@ -9,7 +9,7 @@ require_relative 'environment_overrides'
9
9
  module Workspaces
10
10
  class FrontDoor
11
11
  WORKSPACE_ROUTE = %r{\A/workspaces/([a-z0-9]+(?:-[a-z0-9]+)*)
12
- (?:/(status|start|prepare|restart|stop|environment))?\z}x
12
+ (?:/(status|start|prepare|restart|stop|remove|update-pr|environment))?\z}x
13
13
 
14
14
  def initialize(coordinator: Coordinator.new, registry: Registry.new)
15
15
  @coordinator = coordinator
@@ -35,6 +35,7 @@ module Workspaces
35
35
  private
36
36
 
37
37
  def route(request)
38
+ return favicon(request) if request.path_info == '/favicon.svg'
38
39
  return index(request) if ['/', '/workspaces'].include?(request.path_info)
39
40
  return create(request) if request.path_info == '/workspaces/create'
40
41
  pr_match = %r{\A/pr/([1-9][0-9]*)\z}.match(request.path_info)
@@ -43,6 +44,12 @@ module Workspaces
43
44
  workspace_route(request)
44
45
  end
45
46
 
47
+ def favicon(request)
48
+ return response(405, 'Use GET or HEAD') unless safe?(request)
49
+
50
+ response(200, DashboardView.favicon, type: 'image/svg+xml', head: request.head?)
51
+ end
52
+
46
53
  def workspace_route(request)
47
54
  match = WORKSPACE_ROUTE.match(request.path_info)
48
55
  return response(404, 'Not found. Visit /workspaces.') unless match
@@ -69,7 +76,7 @@ module Workspaces
69
76
  def index(request)
70
77
  return response(405, 'Use GET or HEAD') unless safe?(request)
71
78
 
72
- response(200, DashboardView.index(@registry.list), type: 'text/html', head: request.head?)
79
+ response(200, DashboardView.index(@coordinator.inventory), type: 'text/html', head: request.head?)
73
80
  end
74
81
 
75
82
  def action_request(request, id, action)
@@ -77,6 +84,8 @@ module Workspaces
77
84
  return status_response(request, id, action) if action.nil? || action == 'status'
78
85
  return response(405, 'Use POST') unless request.post?
79
86
  return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
87
+ return remove(request, id) if action == 'remove'
88
+ return update_pr(id) if action == 'update-pr'
80
89
 
81
90
  perform_action(id, action)
82
91
  redirect(Config.dashboard_url(id))
@@ -126,6 +135,26 @@ module Workspaces
126
135
  end
127
136
  end
128
137
 
138
+ def remove(request, id)
139
+ unless request.media_type == 'application/x-www-form-urlencoded'
140
+ return response(415, 'Deletion requires a form-encoded confirmation')
141
+ end
142
+ return response(413, 'Deletion confirmation is too large') if request.content_length.to_i > 512
143
+ return response(422, 'Type the exact workspace ID to confirm deletion') unless request.POST['confirm_id'] == id
144
+
145
+ @registry.remove(id, force: true)
146
+ redirect("#{Config.public_origin}/workspaces")
147
+ rescue ArgumentError, Worktree::CommandFailedError, SystemCallError
148
+ response(409, 'Workspace was not removed. Check the server logs and retry when safe.')
149
+ end
150
+
151
+ def update_pr(id)
152
+ @registry.update_pr(id)
153
+ redirect(Config.dashboard_url(id))
154
+ rescue Worktree::UpdateError
155
+ redirect(Config.dashboard_url(id))
156
+ end
157
+
129
158
  def create(request)
130
159
  return response(405, 'Use POST') unless request.post?
131
160
  return response(403, 'Creation requires the configured dashboard origin') unless same_origin?(request)
@@ -50,10 +50,16 @@ module Workspaces
50
50
 
51
51
  def actions
52
52
  return [] if busy?
53
- return %w(prepare restart stop).map { |key| action(key, false) } if status == 'ready'
54
- return [retry_action] if %w(error interrupted).include?(status)
53
+ available = if status == 'ready'
54
+ %w(prepare restart stop).map { |key| action(key, false) }
55
+ elsif %w(error interrupted).include?(status)
56
+ [retry_action]
57
+ else
58
+ [action('start', true)]
59
+ end
60
+ available << { 'key' => 'update-pr', 'label' => 'Update from PR', 'primary' => false } if @snapshot.dig('source', 'kind') == 'pr'
61
+ available
55
62
 
56
- [action('start', true)]
57
63
  end
58
64
 
59
65
  def retry_action
@@ -1,5 +1,6 @@
1
1
  require_relative 'worktree'
2
2
  require_relative 'state_store'
3
+ require_relative 'backend'
3
4
 
4
5
  module Workspaces
5
6
  class Registry
@@ -35,6 +36,31 @@ module Workspaces
35
36
  end
36
37
  end
37
38
 
39
+ def remove(id, force: false)
40
+ with_lock(id) do |workspace|
41
+ raise ArgumentError, 'Workspace has uncommitted/untracked work' if !force && workspace.dirty?
42
+
43
+ Backend.new(id).stop!
44
+ workspace.remove!(force: force)
45
+ StateStore.new(Config.state_file).delete(id)
46
+ end
47
+ end
48
+
49
+ def update_pr(id)
50
+ with_lock(id) do |workspace|
51
+ result = workspace.update_from_pr!
52
+ message = result['updated'] ? 'PR checkout updated. Rebuild & restart to run the new code.' : 'PR checkout is already current.'
53
+ StateStore.new(Config.state_file).set(id, 'source_update_message' => message,
54
+ 'source_updated_at' => Time.now.utc.iso8601)
55
+ result
56
+ rescue Worktree::UpdateError, Worktree::CommandFailedError => e
57
+ message = e.is_a?(Worktree::CommandFailedError) ? 'Could not fetch or update the PR checkout; local files were not intentionally discarded' : e.message
58
+ StateStore.new(Config.state_file).set(id, 'source_update_message' => message,
59
+ 'source_updated_at' => Time.now.utc.iso8601)
60
+ raise Worktree::UpdateError, message
61
+ end
62
+ end
63
+
38
64
  private
39
65
 
40
66
  def describe_directory(directory, number)
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.1'
2
+ VERSION = '0.1.3'
3
3
  end
@@ -9,6 +9,7 @@ module Workspaces
9
9
  class Worktree
10
10
  class CommandFailedError < StandardError; end
11
11
  class AuthenticationError < CommandFailedError; end
12
+ class UpdateError < StandardError; end
12
13
  AUTH_FAILURE = Regexp.union(/could not read (?:Username|Password)|terminal prompts disabled|Authentication failed/i,
13
14
  /Permission denied \(publickey\)|gh auth login|HTTP 401|HTTP 403/i)
14
15
  METADATA = '.workspace.json'.freeze
@@ -90,6 +91,32 @@ module Workspaces
90
91
  git!(*args, path.to_s)
91
92
  end
92
93
 
94
+ def update_from_pr!
95
+ require_owned!
96
+ source = metadata.fetch('source')
97
+ raise UpdateError, 'Only a PR workspace can be updated from a PR' unless source['kind'] == 'pr'
98
+ raise UpdateError, 'Resolve the retained workspace update stash before updating again' if update_stash?
99
+
100
+ number = source.fetch('number')
101
+ raise UpdateError, 'Invalid PR number in workspace metadata' unless number.to_s.match?(/\A[1-9][0-9]*\z/)
102
+
103
+ target = fetch_pr_head(number)
104
+ current = git!('rev-parse', 'HEAD', cwd: path)
105
+ raise UpdateError, 'Workspace branch has diverged from the PR; no files were changed' unless ancestor?(current, target)
106
+ return { 'updated' => false, 'head' => current } if current == target
107
+
108
+ stash = save_changes
109
+ begin
110
+ git!('-c', 'core.hooksPath=/dev/null', 'merge', '--ff-only', target, cwd: path)
111
+ rescue CommandFailedError
112
+ restore_changes(stash) if stash
113
+ raise UpdateError, 'Could not fast-forward the workspace; local changes were restored'
114
+ end
115
+
116
+ restore_changes(stash) if stash
117
+ { 'updated' => true, 'head' => target }
118
+ end
119
+
93
120
  def git!(*args, cwd: Config.repo_root)
94
121
  output, status = Open3.capture2e(git_environment, 'git', *args, chdir: cwd.to_s)
95
122
  raise_authentication_error!(output) unless status.success?
@@ -100,8 +127,61 @@ module Workspaces
100
127
 
101
128
  private
102
129
 
130
+ def fetch_pr_head(number)
131
+ ref = "refs/workspaces/updates/#{id}"
132
+ git!('fetch', 'origin', "+pull/#{number}/head:#{ref}", cwd: path)
133
+ target = git!('rev-parse', '--verify', "#{ref}^{commit}", cwd: path)
134
+ git!('update-ref', '-d', ref, cwd: path)
135
+ target
136
+ end
137
+
138
+ def ancestor?(current, target)
139
+ _, status = Open3.capture2e(git_environment, 'git', 'merge-base', '--is-ancestor', current, target,
140
+ chdir: path.to_s)
141
+ return true if status.exitstatus == 0
142
+ return false if status.exitstatus == 1
143
+
144
+ raise CommandFailedError, 'Unable to check PR ancestry'
145
+ end
146
+
147
+ def save_changes
148
+ return nil unless dirty?
149
+
150
+ previous = git!('rev-parse', '--verify', '-q', 'refs/stash', cwd: path) if stash_exists?
151
+ git!('stash', 'push', '--include-untracked', '-m', "workspaces update #{id}", cwd: path)
152
+ current = git!('rev-parse', '--verify', 'refs/stash', cwd: path)
153
+ raise UpdateError, 'Unable to save workspace changes; check the worktree and stash before retrying' if current == previous || dirty?
154
+
155
+ current
156
+ end
157
+
158
+ def stash_exists?
159
+ _, status = Open3.capture2e(git_environment, 'git', 'rev-parse', '--verify', '-q', 'refs/stash',
160
+ chdir: path.to_s)
161
+ status.success?
162
+ end
163
+
164
+ def update_stash?
165
+ git!('stash', 'list', '--format=%gs', cwd: path).lines.any? do |line|
166
+ line.chomp.end_with?("workspaces update #{id}")
167
+ end
168
+ end
169
+
170
+ def restore_changes(stash)
171
+ return unless stash
172
+
173
+ begin
174
+ git!('stash', 'apply', '--index', stash, cwd: path)
175
+ rescue CommandFailedError
176
+ raise UpdateError, "Workspace changes could not be reapplied. Stash #{stash} is retained for manual recovery; resolve worktree conflicts before retrying"
177
+ end
178
+ return unless git!('rev-parse', 'refs/stash', cwd: path) == stash
179
+
180
+ git!('stash', 'drop', 'stash@{0}', cwd: path)
181
+ end
182
+
103
183
  def git_environment
104
- { 'GIT_TERMINAL_PROMPT' => '0', 'GIT_ASKPASS' => '/bin/false', 'SSH_ASKPASS' => '/bin/false',
184
+ { 'GIT_MASTER' => '1', 'GIT_TERMINAL_PROMPT' => '0', 'GIT_ASKPASS' => '/bin/false', 'SSH_ASKPASS' => '/bin/false',
105
185
  'GCM_INTERACTIVE' => 'Never', 'GIT_SSH_COMMAND' => noninteractive_ssh }
106
186
  end
107
187
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workspaces
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Workspaces contributors
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack