workspaces 0.1.13 → 0.1.14

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: 1d730b2e0f349781b10fe24beefc003403f6709460fbe4e0420853988c05f1e5
4
- data.tar.gz: '069ee33491c24526d1644a8877386a95720e8e61457a15296ba2da604067860a'
3
+ metadata.gz: 160b209187a95a674c6ce4936b4b2863ed5893ac8331f1f0345d550cf09f2f2d
4
+ data.tar.gz: ba7ef0dd3ed8c913ea29683f641c1d4ac314ee15a1c201dd45634d45a8a980bc
5
5
  SHA512:
6
- metadata.gz: b2174d3fa1eeb55ce8fffe1dba27a7eaab267fed9904c9b216f4c256a150006d83dfb2100768dc49e473fd54f7a3e687f2dc9f9f159cb1c1df948d128079d7a3
7
- data.tar.gz: 32fbfbb3e6c5334fafa03bb2bbc74f6039b4e3d17ff5a19a58422fa3314af9be0ae0eaace1d4579f2926c5a5c156f0efa074c1e9aeb2dae04d6b19f8be08a872
6
+ metadata.gz: 978a8f06eda4d6d9c0c443499f1cf28f41c33800b3394b40fb56dd8771e0f1283001a4e24effcb3f7c52563023a18e1f48bfe884f97bd255642ff9379b9ea095
7
+ data.tar.gz: 8162b0c59925b414a1af0ff511fcc6bee2d79f83bb42d452614ef68f79c7ee52e42cb89531c21c5e77b29bc76b76cdfea4f4b5fff73fff2217b49217a0321a2c
data/README.md CHANGED
@@ -39,6 +39,42 @@ The returned JSON includes the workspace ID, dashboard URL, and preview URL. You
39
39
  create from a pull request with `create --pr NUMBER`, or create a new branch with
40
40
  `create --new-branch NAME --from REF`. Run `help` for lifecycle and other commands.
41
41
 
42
+ ### Git LFS
43
+
44
+ If the project keeps large files in [Git LFS](https://git-lfs.com), a new workspace does not
45
+ copy them. Creating a workspace (and fast-forwarding a PR workspace to a newer head) leaves
46
+ those files as small pointer files, and `git status` stays clean. Without this, every
47
+ workspace would hold a full second copy of every LFS file, even though the source
48
+ checkout's LFS store is already shared.
49
+
50
+ List the paths a workspace actually needs in `.workspaces.yml`:
51
+
52
+ ```yaml
53
+ lfs:
54
+ fetch:
55
+ - eval/amc/images/golden/**
56
+ - spec/fixtures/images/**
57
+ ```
58
+
59
+ That adds a first step, **Fetch Git LFS files**, which runs
60
+ `git lfs pull --include=<paths>` in the workspace. It appears in the step list with its own
61
+ log, time and place in the progress estimate like any other step, and it runs on `start` and
62
+ `prepare` but not on `restart`. Paths are repository-relative patterns; absolute paths,
63
+ `..`, a leading `-`, commas and control characters are rejected when the recipe loads.
64
+ Omit `lfs` and nothing changes except that files stay as pointers.
65
+
66
+ A few things worth knowing:
67
+
68
+ - Files are fetched from the objects the source repository already has when it can, and from
69
+ the remote otherwise. If neither works (no network, or no credentials for LFS) the step
70
+ fails and the workspace reports it, rather than silently leaving pointers.
71
+ - A file that is still a pointer reads as a ~130 byte text file starting with
72
+ `version https://git-lfs`. If a test or build reads an LFS file, add its path to
73
+ `lfs.fetch`.
74
+ - To fetch more by hand in an existing workspace:
75
+ `workspaces exec <id> -- git lfs pull --include='path/**'`.
76
+ - Workspaces created before this behavior keep their real files; nothing is converted.
77
+
42
78
  ### Step timings
43
79
 
44
80
  Per-step durations are persisted in seconds in workspace step status JSON, so they
@@ -7,7 +7,9 @@ module Workspaces
7
7
  class Invalid < ArgumentError; end
8
8
  RESERVED_ENV = %w(WORKSPACE_ID WORKSPACE_PATH WORKSPACE_REPO_ROOT WORKSPACE_PORT WORKSPACE_URL
9
9
  WORKSPACE_HOST WORKSPACE_CACHE_DIR WORKSPACE_RUN_ID).freeze
10
- attr_reader :steps, :environment, :default_editable_env, :environment_presets
10
+ LFS_STEP_ID = 'lfs'.freeze
11
+ LFS_STEP_NAME = 'Fetch Git LFS files'.freeze
12
+ attr_reader :steps, :environment, :default_editable_env, :environment_presets, :lfs_fetch
11
13
 
12
14
  def initialize(root: Config.repo_root)
13
15
  @root = root
@@ -16,7 +18,9 @@ module Workspaces
16
18
  @default_editable_env = validate_editable_env!(document.fetch('default_editable_env', {}))
17
19
  @environment_presets = validate_environment_presets!(document.fetch('environment_presets', {}))
18
20
  @environment = validate_env!(document.fetch('env', {}))
21
+ @lfs_fetch = validate_lfs!(document.fetch('lfs', {}))
19
22
  @steps = document.fetch('steps').map { |step| validate_step!(step) }
23
+ @steps = [lfs_step] + @steps unless lfs_fetch.empty?
20
24
  validate_editable_overlap!
21
25
  validate_sequence!
22
26
  rescue Errno::ENOENT
@@ -29,6 +33,13 @@ module Workspaces
29
33
  restart ? steps.drop(steps.index { |step| step['background'] }) : steps
30
34
  end
31
35
 
36
+ # Files fetched into the workspace come from the LFS objects the source repository already
37
+ # has, then are linked in by `git lfs pull`; the source's store is shared by every workspace.
38
+ def lfs_step
39
+ { 'id' => LFS_STEP_ID, 'name' => LFS_STEP_NAME, 'timeout' => 600, 'env' => {}, 'background' => false,
40
+ 'run' => ['git', 'lfs', 'pull', "--include=#{lfs_fetch.join(',')}"] }
41
+ end
42
+
32
43
  def command(step, context)
33
44
  command = step.fetch('run')
34
45
  return ['sh', '-c', command] if command.is_a?(String)
@@ -50,7 +61,7 @@ module Workspaces
50
61
  raise Invalid,
51
62
  'Configuration must be a mapping with version: 1'
52
63
  end
53
- unknown_keys!(document, %w(version env default_editable_env environment_presets steps))
64
+ unknown_keys!(document, %w(version env default_editable_env environment_presets lfs steps))
54
65
  raise Invalid, 'steps must be a nonempty array' unless document['steps'].is_a?(Array) && !document['steps'].empty?
55
66
  end
56
67
 
@@ -100,6 +111,26 @@ module Workspaces
100
111
  'timeout applies to foreground steps, not the server lifetime'
101
112
  end
102
113
 
114
+ # `lfs: { fetch: [path, ...] }` names the Git LFS files a workspace needs. Everything else
115
+ # stays a pointer. Paths are repository-relative globs handed to `git lfs pull --include`, so
116
+ # they are checked for the things that would make that unsafe or surprising.
117
+ def validate_lfs!(value)
118
+ raise Invalid, 'lfs must be a mapping' unless value.is_a?(Hash)
119
+
120
+ unknown_keys!(value, %w(fetch))
121
+ paths = value.fetch('fetch', [])
122
+ raise Invalid, 'lfs.fetch must be an array of paths' unless paths.is_a?(Array)
123
+
124
+ paths.each do |path|
125
+ unless path.is_a?(String) && !path.strip.empty? && path == path.strip
126
+ raise Invalid, 'lfs.fetch paths must be nonempty trimmed strings'
127
+ end
128
+ raise Invalid, 'lfs.fetch paths cannot contain commas or control characters' if path.match?(/[,[:cntrl:]]/)
129
+ raise Invalid, 'lfs.fetch paths must be relative to the repository' if path.start_with?('/', '-') || path.split('/').include?('..')
130
+ end
131
+ paths.uniq
132
+ end
133
+
103
134
  def validate_env!(values)
104
135
  raise Invalid, 'env must be a mapping' unless values.is_a?(Hash)
105
136
  values.each do |key, value|
@@ -1,3 +1,3 @@
1
1
  module Workspaces
2
- VERSION = '0.1.13'
2
+ VERSION = '0.1.14'
3
3
  end
@@ -12,6 +12,12 @@ module Workspaces
12
12
  class UpdateError < StandardError; end
13
13
  AUTH_FAILURE = Regexp.union(/could not read (?:Username|Password)|terminal prompts disabled|Authentication failed/i,
14
14
  /Permission denied \(publickey\)|gh auth login|HTTP 401|HTTP 403/i)
15
+ # A project that keeps large files in Git LFS would otherwise copy every one of them into
16
+ # every workspace at checkout. With smudging skipped they arrive as small pointer files and
17
+ # `git status` stays clean, so nothing downloads until a recipe asks for it (`lfs.fetch`).
18
+ # Applied only where the gem writes files from Git itself; it is not applied to stash or
19
+ # apply, which must never swap a real file someone fetched for its pointer.
20
+ LFS_POINTERS_ONLY = { 'GIT_LFS_SKIP_SMUDGE' => '1' }.freeze
15
21
  METADATA = '.workspace.json'.freeze
16
22
  PR_METADATA = '.related-pr'.freeze
17
23
  attr_reader :id, :path
@@ -107,7 +113,7 @@ module Workspaces
107
113
 
108
114
  stash = save_changes
109
115
  begin
110
- git!('-c', 'core.hooksPath=/dev/null', 'merge', '--ff-only', target, cwd: path)
116
+ git!('-c', 'core.hooksPath=/dev/null', 'merge', '--ff-only', target, cwd: path, env: LFS_POINTERS_ONLY)
111
117
  rescue CommandFailedError
112
118
  restore_changes(stash) if stash
113
119
  raise UpdateError, 'Could not fast-forward the workspace; local changes were restored'
@@ -117,8 +123,8 @@ module Workspaces
117
123
  { 'updated' => true, 'head' => target }
118
124
  end
119
125
 
120
- def git!(*args, cwd: Config.repo_root)
121
- output, status = Open3.capture2e(git_environment, 'git', *args, chdir: cwd.to_s)
126
+ def git!(*args, cwd: Config.repo_root, env: {})
127
+ output, status = Open3.capture2e(git_environment.merge(env), 'git', *args, chdir: cwd.to_s)
122
128
  raise_authentication_error!(output) unless status.success?
123
129
  raise CommandFailedError, "git #{args.join(' ')} failed:\n#{output}" unless status.success?
124
130
 
@@ -203,7 +209,7 @@ module Workspaces
203
209
  raise ArgumentError, 'Workspace branch must not start with a dash' if target_branch.start_with?('-')
204
210
 
205
211
  Config.worktrees_dir.mkpath
206
- git!('-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', target_branch, path.to_s, sha)
212
+ git!('-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', target_branch, path.to_s, sha, env: LFS_POINTERS_ONLY)
207
213
  end
208
214
 
209
215
  def initialize_worktree!(source, target_branch)
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.13
4
+ version: 0.1.14
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-10-20 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack