pray-cli 1.18.1 → 1.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91a549f989bb5872cf7307c16101a89c479bda9f919dd4f969917a80f4bf5958
4
- data.tar.gz: 01a7042cad4e383a95ca2b15d698b9e00a46707d27b2885381ce57f6775cb15b
3
+ metadata.gz: 4eb951f0ca0abc5ac0aa68ab27e63e4f778c92453fddb9a7cca0115dac1526d9
4
+ data.tar.gz: 28ba1073042c0c2a77138e35a9ae80688cb1a6075dd16cba5ee1b9aef2c9c3af
5
5
  SHA512:
6
- metadata.gz: 52f904573df3f97eb3ad240388db1ac7d81d36cd66fe24b43be8cf32f392d59abf6283fc496a92b9844e40cec4d7058bf8db54fb97716164635c395c95a27796
7
- data.tar.gz: ccc7110154721dbc019d02c37ecdbeaaac225ccf702e8d1007cfe3af4b3f54f35627876fb78f15eb5617e691c7c7ca7016c9c05853b1719655545a087baf8eef
6
+ metadata.gz: 260a99edc903d99d235b2d9dd40904490fbcc392d67a07a4323d43b1359fead4428976745e438e17767948a1494262daa97b87650965ffc22366267c8faf5226
7
+ data.tar.gz: f031ff7ebaeceee86829abac7e2f99f060be902ebc586c00e5ad67135d25958248ac7c4fd5425a12068b67d1a01ebd747cacf53501f2c81cc68f3bdcbcfd838d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.20.0 (2026-09-18)
4
+
5
+ - Fetch a locked git catalog revision during `pray install` when the local cache does not have that commit yet. `pray install --offline` still refuses a missing commit.
6
+ - Refuse `pray install --offline` when a git catalog is not already in the local cache, instead of cloning it. A global cache seed still works.
7
+ - Honour a locked git revision for a local file catalog. Install does not read the origin worktree after that checkout fails.
8
+ - Keep git catalog objects in the shared cache. The project catalog is a file tree without a git directory.
9
+ - When a locked git revision is missing from a shallow cache, fetch the missing history instead of only that one commit.
10
+ - Keep an unchanged published version when a later `pray publish` uses a different publisher label.
11
+ - Keep `pray drift` clean after `pray install` when a compose block adds a local file after a package dest already had.
12
+
13
+ ## 1.19.0 (2026-09-18)
14
+
15
+ - Declare named `publish` remotes in Prayfile (RFC 0118). `pray publish` uses them when dest flags are omitted, and publishes only path-owned packages.
16
+
3
17
  ## 1.18.1 (2026-09-17)
4
18
 
5
19
  - Fix `pray package` and `pray publish` when the CLI cannot create a staging directory.
@@ -2,16 +2,64 @@
2
2
 
3
3
  module Pray
4
4
  module CLI
5
- def publish_command(roots:, servers:)
5
+ def publish_command(roots:, servers:, to: [], dry_run: false)
6
6
  project = resolve_current_project
7
- roots.each { |root| Publish.publish_to_root(project, root) }
8
- servers.each { |server| Publish.publish_to_server(project, server) }
7
+ dests = PublishSelect.resolve_publish_destinations(
8
+ project.manifest.publish_remotes || [],
9
+ PublishSelect::PublishCliDest.new(to: to, roots: roots, servers: servers),
10
+ Dir.pwd
11
+ )
12
+ if dry_run
13
+ print_publish_plan(project, dests)
14
+ return
15
+ end
16
+ dests.each do |dest|
17
+ packages = selected_publish_packages(project, dest.packages)
18
+ filtered = project.dup
19
+ filtered.packages = packages
20
+ Publish.publish_to_root(filtered, dest.root) if dest.root
21
+ Publish.publish_to_server(filtered, dest.server) if dest.server
22
+ end
9
23
  end
10
24
 
11
- def serve_command(root:, host:, port:, stdio:)
25
+ def selected_publish_packages(project, listed)
26
+ allowed = PublishRemote.allowed_publish_names(project.manifest, listed)
27
+ selected = project.packages.select { |package| allowed.include?(package.declaration.name) }
28
+ if selected.empty?
29
+ raise Error.usage("no path packages to publish; remote dependencies are not published")
30
+ end
31
+ selected.each { |package| package.spec.require_release_version! }
32
+ selected
33
+ end
34
+
35
+ def print_publish_plan(project, dests)
36
+ dests.each do |dest|
37
+ packages = selected_publish_packages(project, dest.packages)
38
+ target = dest.root || dest.server || dest.name
39
+ packages.each do |package|
40
+ puts "publish #{package.declaration.name} #{package.spec.version} -> #{dest.name} (#{target})"
41
+ end
42
+ end
43
+ end
44
+
45
+ def serve_command(root:, host:, port:, stdio:, to: nil)
12
46
  raise Error.unsupported("serve --stdio is not implemented yet in pray-cli Ruby") if stdio
13
47
 
14
- Serve.run_server(root: root, host: host, port: port)
48
+ Serve.run_server(root: optional_path_remote_root(to, root), host: host, port: port)
49
+ end
50
+
51
+ def optional_path_remote_root(to, root)
52
+ remotes = begin
53
+ Pray.parse_manifest(Pray.read_manifest_text(manifest_path)).publish_remotes || []
54
+ rescue Error
55
+ return root if to.nil?
56
+
57
+ raise
58
+ end
59
+ return root if remotes.empty? && to.nil?
60
+
61
+ cli_root = (root == ".") ? nil : root
62
+ PublishSelect.resolve_path_remote(remotes, to, cli_root, Dir.pwd)
15
63
  end
16
64
  end
17
65
  end
@@ -34,6 +34,32 @@ module Pray
34
34
  FileUtils.mkdir_p(File.join(distribution_root, "v1", "artifacts"))
35
35
  Publish.write_registry_index(distribution_root, RegistryIndex.new)
36
36
  Distribution.write_settings(distribution_root)
37
+ maybe_declare_publish_remote(Dir.pwd, distribution_root)
38
+ end
39
+
40
+ def maybe_declare_publish_remote(project_root, distribution_root)
41
+ manifest_path_value = File.join(project_root, "Prayfile")
42
+ return unless File.exist?(manifest_path_value)
43
+
44
+ text = File.read(manifest_path_value)
45
+ manifest = Pray.parse_manifest(text)
46
+ return unless (manifest.publish_remotes || []).empty?
47
+
48
+ relative = if distribution_root == project_root
49
+ "."
50
+ else
51
+ prefix = project_root.end_with?("/") ? project_root : "#{project_root}/"
52
+ distribution_root.start_with?(prefix) ? distribution_root.delete_prefix(prefix).tr("\\", "/") : "prayers"
53
+ end
54
+ statement = %(publish "prayers", path: "#{relative}")
55
+ lines = text.split("\n")
56
+ insertion = lines.rindex { |line| line.lstrip.start_with?("source ") }
57
+ insertion ||= lines.index { |line| line.lstrip.start_with?("prayfile ") }
58
+ insertion = insertion ? insertion + 1 : 1
59
+ lines.insert(insertion, statement)
60
+ updated = lines.join("\n")
61
+ updated += "\n" unless updated.end_with?("\n")
62
+ File.write(manifest_path_value, updated)
37
63
  end
38
64
 
39
65
  def repo_distribution_root(root)
@@ -53,7 +53,12 @@ module Pray
53
53
 
54
54
  def package_command
55
55
  project = resolve_current_project
56
- project.packages.each do |package|
56
+ allowed = PublishRemote.path_owned_package_names(project.manifest)
57
+ packages = project.packages.select { |package| allowed.include?(package.declaration.name) }
58
+ if packages.empty?
59
+ raise Error.usage("no path packages to package; remote dependencies are not packed")
60
+ end
61
+ packages.each do |package|
57
62
  package.spec.require_release_version!
58
63
  output_path = Archive.package_archive_path(package.declaration.name, package.spec.version)
59
64
  Archive.write_package_archive(package, output_path)
data/lib/pray/cli/help.rb CHANGED
@@ -23,9 +23,9 @@ module Pray
23
23
  ].freeze
24
24
 
25
25
  DISTRIBUTION_COMMANDS = [
26
- "publish --root PATH [--server URL ...]",
26
+ "publish [--root PATH|--server URL|--to NAME] [--dry-run]",
27
27
  "login --server URL --email EMAIL",
28
- "serve [--root PATH] [--host HOST] [--port PORT] [--stdio]",
28
+ "serve [--root PATH | --to NAME] [--host HOST] [--port PORT] [--stdio]",
29
29
  "sync [--root PATH] [--peer URL ...]",
30
30
  "confess <package> | --from-lock SPAN_ID [--accepted|--rejected]"
31
31
  ].freeze
@@ -146,9 +146,11 @@ module Pray
146
146
  "vendor" => "copy resolved packages locally\n\nUsage: pray vendor",
147
147
  "clean" => "remove local cache and vendor trees, or only unused registry entries\n\nUsage: pray clean [--unused]",
148
148
  "publish" => <<~TEXT.strip,
149
- upload packages to a registry or local root
149
+ upload path packages to a registry or local root
150
150
 
151
- Usage: pray publish --root PATH [--server URL ...]
151
+ Usage: pray publish [--root PATH] [--server URL ...] [--to NAME] [--dry-run]
152
+
153
+ Prayfile publish remotes supply dests when flags are omitted.
152
154
  TEXT
153
155
  "login" => <<~TEXT.strip,
154
156
  authenticate to a registry server
@@ -158,7 +160,7 @@ module Pray
158
160
  "serve" => <<~TEXT.strip,
159
161
  run a local registry server
160
162
 
161
- Usage: pray serve [--root PATH] [--host HOST] [--port PORT] [--stdio]
163
+ Usage: pray serve [--root PATH | --to NAME] [--host HOST] [--port PORT] [--stdio]
162
164
  TEXT
163
165
  "sync" => <<~TEXT.strip,
164
166
  sync packages with peer registries
@@ -141,26 +141,33 @@ module Pray
141
141
  def parse_publish_arguments(arguments)
142
142
  roots = []
143
143
  servers = []
144
+ to = []
145
+ dry_run = false
144
146
  while (argument = arguments.shift)
145
147
  case argument
146
148
  when "--root"
147
149
  roots << arguments.shift
148
150
  when "--server"
149
151
  servers << arguments.shift
152
+ when "--to"
153
+ to << arguments.shift
154
+ when "--dry-run"
155
+ dry_run = true
156
+ when "--signing-key"
157
+ arguments.shift
150
158
  else
151
159
  raise Error.unsupported("unexpected publish argument: #{argument}")
152
160
  end
153
161
  end
154
- raise Error.unsupported("publish requires at least one --root PATH or --server URL") if roots.empty? && servers.empty?
155
-
156
- {roots: roots, servers: servers}
162
+ {roots: roots, servers: servers, to: to, dry_run: dry_run}
157
163
  end
158
164
 
159
165
  def parse_serve_arguments(arguments)
160
- options = {root: ".", host: "127.0.0.1", port: 7429, stdio: false}
166
+ options = {root: ".", to: nil, host: "127.0.0.1", port: 7429, stdio: false}
161
167
  while (argument = arguments.shift)
162
168
  case argument
163
169
  when "--root" then options[:root] = arguments.shift
170
+ when "--to" then options[:to] = arguments.shift
164
171
  when "--host" then options[:host] = arguments.shift
165
172
  when "--port" then options[:port] = Integer(arguments.shift)
166
173
  when "--stdio" then options[:stdio] = true
@@ -79,6 +79,7 @@ module Pray
79
79
  targets: [],
80
80
  packages: manifest.packages.map { |package| clone_package(package) },
81
81
  local: manifest.local.map { |local| local.dup },
82
+ publish_remotes: (manifest.publish_remotes || []).map(&:dup),
82
83
  symbols: manifest.symbols.dup,
83
84
  render: manifest.render.dup
84
85
  )
@@ -12,6 +12,11 @@ module Pray
12
12
  manifest.sources.each { |source| lines << format_source(source) }
13
13
  end
14
14
 
15
+ unless (manifest.publish_remotes || []).empty?
16
+ lines << ""
17
+ manifest.publish_remotes.each { |remote| lines << format_publish_remote(remote) }
18
+ end
19
+
15
20
  unless manifest.symbols.empty?
16
21
  lines << ""
17
22
  lines << "pray do"
@@ -109,6 +114,18 @@ module Pray
109
114
  parts.join(", ")
110
115
  end
111
116
 
117
+ def format_publish_remote(remote)
118
+ parts = [%(publish "#{remote.name}")]
119
+ parts << %(path: "#{remote.path}") if remote.path
120
+ parts << %("#{remote.url}") if remote.url
121
+ return parts.join(", ") if remote.packages.empty?
122
+
123
+ lines = ["#{parts.join(", ")} do"]
124
+ remote.packages.each { |name| lines << %( pray "#{name}") }
125
+ lines << "end"
126
+ lines.join("\n")
127
+ end
128
+
112
129
  def format_destination_entry(entry, manifest)
113
130
  if entry.kind == "local"
114
131
  return %(pray "#{entry.path}")
@@ -1,33 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
- require "pathname"
5
3
  require_relative "error"
6
4
  require_relative "hashing"
7
5
  require_relative "git_run"
8
- require_relative "git_clone"
6
+ require_relative "git_store"
7
+ require_relative "git_materialize"
9
8
 
10
9
  module Pray
11
10
  module GitCache
12
11
  module_function
13
12
 
14
- def ensure_git_repository(project_root, clone_url, refresh:, pinned_revision:, sparse_subdir:)
15
- shared = git_source_cache_directory(project_root, clone_url)
16
- ensure_shared_git_repository(project_root, clone_url, shared, refresh: refresh, pinned_revision: pinned_revision)
17
- checkout = git_source_cache_directory(project_root, clone_url, sparse_subdir)
18
- if checkout != shared
19
- ensure_linked_worktree(shared, checkout)
20
- GitClone.apply_sparse_checkout(checkout, sparse_subdir)
21
- if pinned_revision
22
- checkout_git_revision(checkout, clone_url, pinned_revision, refresh)
23
- elsif refresh
24
- GitRun.run_git(checkout, "reset", "--hard", git_head_revision(shared))
25
- end
26
- return [checkout, git_head_revision(checkout)]
27
- end
28
-
29
- GitClone.apply_sparse_checkout(shared)
30
- [shared, git_head_revision(shared)]
13
+ def ensure_git_repository(project_root, clone_url, refresh:, pinned_revision:, sparse_subdir:, offline: false)
14
+ db, revision = GitStore.ensure_global_git_db(
15
+ clone_url,
16
+ pinned_revision: pinned_revision,
17
+ refresh: refresh,
18
+ offline: offline,
19
+ working_directory: project_root
20
+ )
21
+ catalog = git_source_cache_directory(project_root, clone_url, sparse_subdir)
22
+ GitMaterialize.materialize_catalog_tree(db, catalog, revision, sparse_subdir, refresh)
23
+ [catalog, revision]
31
24
  end
32
25
 
33
26
  def git_source_cache_directory(project_root, clone_url, subdir = nil)
@@ -36,6 +29,9 @@ module Pray
36
29
  end
37
30
 
38
31
  def git_source_cached_repository(project_root, clone_url)
32
+ global_cache = GitStore.global_git_cache_directory(clone_url)
33
+ return global_cache if global_cache && GitStore.global_git_cache_ready?(global_cache)
34
+
39
35
  shared = git_source_cache_directory(project_root, clone_url)
40
36
  return shared if git_checkout?(shared)
41
37
 
@@ -57,60 +53,6 @@ module Pray
57
53
  Hashing.sha256_prefixed(text)[7, 16]
58
54
  end
59
55
 
60
- def ensure_shared_git_repository(project_root, clone_url, shared, refresh:, pinned_revision:)
61
- if File.directory?(File.join(shared, ".git"))
62
- if pinned_revision
63
- checkout_git_revision(shared, clone_url, pinned_revision, refresh)
64
- elsif refresh
65
- refresh_git_worktree(shared, clone_url)
66
- end
67
- refresh_global_from_project(clone_url, shared) if refresh
68
- return
69
- end
70
-
71
- FileUtils.rm_rf(shared) if File.exist?(shared)
72
- FileUtils.mkdir_p(File.dirname(shared))
73
- seeded = seed_git_cache_from_global(clone_url, shared, project_root)
74
- if seeded
75
- GitRun.run_git(shared, "remote", "set-url", "origin", clone_url)
76
- else
77
- GitClone.clone_git_cache(project_root, clone_url, shared, quiet: false)
78
- mirror_git_cache_to_global(clone_url, shared)
79
- end
80
- GitClone.apply_sparse_checkout(shared)
81
- if pinned_revision
82
- checkout_git_revision(shared, clone_url, pinned_revision, true)
83
- elsif refresh && seeded
84
- refresh_git_worktree(shared, clone_url)
85
- end
86
- refresh_global_from_project(clone_url, shared) if refresh && seeded
87
- end
88
-
89
- def ensure_linked_worktree(shared, checkout)
90
- return if same_object_store?(shared, checkout)
91
-
92
- FileUtils.rm_rf(checkout) if File.exist?(checkout)
93
- FileUtils.mkdir_p(File.dirname(checkout))
94
- return if GitRun.try_run_git(shared, "worktree", "add", "--detach", "--no-checkout", checkout)
95
-
96
- GitRun.run_git(shared, "worktree", "add", "--detach", checkout)
97
- end
98
-
99
- def same_object_store?(shared, checkout)
100
- return false unless git_checkout?(checkout)
101
-
102
- git_common_dir(shared) == git_common_dir(checkout)
103
- end
104
-
105
- def git_common_dir(repository)
106
- output, status = GitRun.capture_git(repository, "rev-parse", "--git-common-dir")
107
- raise Error.resolution(GitRun.command_error("git rev-parse --git-common-dir", output)) unless status.success?
108
-
109
- reported = output.strip
110
- path = Pathname.new(reported).absolute? ? reported : File.expand_path(reported, repository)
111
- File.realpath(path)
112
- end
113
-
114
56
  def origin_matches?(repository, clone_url)
115
57
  output, status = GitRun.capture_git(repository, "remote", "get-url", "origin")
116
58
  return false unless status.success?
@@ -118,75 +60,5 @@ module Pray
118
60
  origin = output.strip.delete_prefix("git+")
119
61
  origin == clone_url
120
62
  end
121
-
122
- def global_cache_root
123
- return ENV["PRAY_CACHE"] if ENV["PRAY_CACHE"]
124
- return File.join(ENV["PRAY_HOME"], "cache") if ENV["PRAY_HOME"]
125
-
126
- home = ENV["HOME"]
127
- home ? File.join(home, ".cache", "pray") : nil
128
- end
129
-
130
- def global_git_cache_directory(clone_url)
131
- root = global_cache_root
132
- root ? File.join(root, "git", cache_key(clone_url)) : nil
133
- end
134
-
135
- def global_git_cache_ready?(global_cache)
136
- File.directory?(File.join(global_cache, ".git")) || File.file?(File.join(global_cache, "HEAD"))
137
- end
138
-
139
- def seed_git_cache_from_global(clone_url, destination, working_directory)
140
- global_cache = global_git_cache_directory(clone_url)
141
- return false unless global_cache && global_git_cache_ready?(global_cache)
142
-
143
- GitClone.clone_git_cache(working_directory, global_cache, destination, quiet: true)
144
- true
145
- end
146
-
147
- def mirror_git_cache_to_global(clone_url, project_cache)
148
- global_cache = global_git_cache_directory(clone_url)
149
- return unless global_cache
150
- return if global_git_cache_ready?(global_cache)
151
-
152
- FileUtils.mkdir_p(File.dirname(global_cache))
153
- FileUtils.rm_rf(global_cache) if File.exist?(global_cache)
154
- GitRun.run_git(File.dirname(project_cache), "clone", "--bare", "--quiet", File.basename(project_cache), global_cache)
155
- end
156
-
157
- def fetch_origin(repository, revision = nil)
158
- extra = revision ? [revision] : []
159
- return if GitRun.try_run_git(repository, "fetch", "--depth", "1", "--filter=blob:none", "origin", *extra)
160
-
161
- GitRun.run_git(repository, "fetch", "--depth", "1", "origin", *extra)
162
- end
163
-
164
- def checkout_git_revision(repository, _clone_url, revision, refresh)
165
- fetch_origin(repository, revision) if refresh
166
- GitRun.run_git(repository, "checkout", "--force", revision)
167
- end
168
-
169
- def refresh_git_worktree(repository, _clone_url)
170
- fetch_origin(repository)
171
- GitRun.run_git(repository, "reset", "--hard", "origin/HEAD")
172
- end
173
-
174
- def refresh_global_from_project(clone_url, project_cache)
175
- global_cache = global_git_cache_directory(clone_url)
176
- return unless global_cache
177
-
178
- FileUtils.rm_rf(global_cache) if global_git_cache_ready?(global_cache) || File.exist?(global_cache)
179
- mirror_git_cache_to_global(clone_url, project_cache)
180
- end
181
-
182
- def git_head_revision(repository)
183
- output, status = GitRun.capture_git(repository, "rev-parse", "HEAD")
184
- raise Error.resolution(GitRun.command_error("git rev-parse HEAD", output)) unless status.success?
185
-
186
- revision = output.strip
187
- raise Error.resolution("git repository has no HEAD revision") if revision.empty?
188
-
189
- revision
190
- end
191
63
  end
192
64
  end
@@ -6,21 +6,17 @@ module Pray
6
6
  module GitClone
7
7
  module_function
8
8
 
9
- def clone_git_cache(working_directory, source, destination, quiet:)
10
- filtered = ["clone", "--depth", "1", "--filter=blob:none", "--sparse", source, destination]
9
+ def clone_bare_git_db(working_directory, source, destination, quiet:)
10
+ local = source.start_with?("file://", "/") ? ["--no-local"] : []
11
+ filtered = ["clone", "--bare", "--depth", "1", "--filter=blob:none", *local, source, destination]
11
12
  filtered.insert(1, "--quiet") if quiet
12
13
  return if GitRun.try_run_git(working_directory, *filtered)
13
14
 
14
- full = ["clone", "--depth", "1", source, destination]
15
+ full = ["clone", "--bare", "--depth", "1", *local, source, destination]
15
16
  full.insert(1, "--quiet") if quiet
16
17
  GitRun.run_git(working_directory, *full)
17
18
  end
18
19
 
19
- def apply_sparse_checkout(repository, subdir = nil)
20
- GitRun.run_git(repository, "sparse-checkout", "init", "--cone")
21
- GitRun.run_git(repository, "sparse-checkout", "set", *catalog_sparse_cones(subdir))
22
- end
23
-
24
20
  def catalog_sparse_cones(subdir)
25
21
  if subdir && !subdir.empty?
26
22
  ["#{subdir}/v1/packages"]
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "git_run"
3
+ require "fileutils"
4
+ require "open3"
4
5
  require_relative "error"
6
+ require_relative "git_clone"
7
+ require_relative "git_run"
5
8
  require_relative "path_safety"
6
9
 
7
10
  module Pray
8
11
  module GitMaterialize
12
+ REVISION_MARKER = ".pray-revision"
13
+ GIT_DIR_MARKER = ".pray-git-dir"
14
+
9
15
  module_function
10
16
 
11
17
  def read_local_artifact_bytes(source_root, artifact)
@@ -28,49 +34,95 @@ module Pray
28
34
  File.binread(path)
29
35
  end
30
36
 
31
- def materialize_catalog_file(source_root, relative)
32
- return if File.file?(File.join(source_root, relative))
33
- return unless sparse_checkout_enabled?(source_root)
34
-
35
- toplevel = git_toplevel(source_root)
36
- return unless toplevel
37
-
38
- repo_relative = repo_relative_text(source_root, relative)
39
- return unless repo_relative
37
+ def materialize_catalog_tree(git_dir, dest, revision, subdir, refresh)
38
+ return if !refresh && catalog_matches?(dest, git_dir, revision)
40
39
 
41
- cone = artifact_sparse_cone(repo_relative)
42
- GitRun.run_git(toplevel, "sparse-checkout", "add", cone) if cone
43
- GitRun.run_git(toplevel, "checkout", "HEAD", "--", repo_relative)
44
- end
40
+ FileUtils.rm_rf(dest) if File.exist?(dest)
41
+ FileUtils.mkdir_p(dest)
42
+ unpacked = GitClone.catalog_sparse_cones(subdir).any? do |prefix|
43
+ unpack_archive_prefix(git_dir, dest, revision, prefix)
44
+ end
45
+ raise Error.resolution("no pray distribution root in git source at revision #{revision}") unless unpacked
45
46
 
46
- def sparse_checkout_enabled?(source_root)
47
- output, status = GitRun.capture_git(source_root, "config", "--get", "core.sparseCheckout")
48
- status.success? && output.strip == "true"
47
+ File.write(File.join(dest, REVISION_MARKER), revision)
48
+ File.write(File.join(dest, GIT_DIR_MARKER), git_dir)
49
49
  end
50
50
 
51
- def git_toplevel(source_root)
52
- output, status = GitRun.capture_git(source_root, "rev-parse", "--show-toplevel")
53
- return unless status.success?
51
+ def materialize_catalog_file(source_root, relative)
52
+ return if File.file?(File.join(source_root, relative))
54
53
 
55
- text = output.strip
56
- text.empty? ? nil : text
54
+ markers = find_catalog_markers(source_root)
55
+ return unless markers
56
+
57
+ full_path = File.join(source_root, relative)
58
+ repo_relative = full_path.delete_prefix(markers.fetch(:work_tree)).delete_prefix("/")
59
+ GitRun.run_git(
60
+ markers.fetch(:git_dir),
61
+ "--work-tree",
62
+ markers.fetch(:work_tree),
63
+ "checkout",
64
+ markers.fetch(:revision),
65
+ "--",
66
+ repo_relative
67
+ )
57
68
  end
58
69
 
59
- def repo_relative_text(source_root, relative)
60
- output, status = GitRun.capture_git(source_root, "rev-parse", "--show-prefix")
61
- return unless status.success?
62
-
63
- prefix = output.strip
64
- prefix.empty? ? relative : File.join(prefix, relative)
70
+ def catalog_matches?(dest, git_dir, revision)
71
+ revision_path = File.join(dest, REVISION_MARKER)
72
+ git_dir_path = File.join(dest, GIT_DIR_MARKER)
73
+ return false unless File.file?(revision_path) && File.file?(git_dir_path)
74
+ return false unless File.read(revision_path).strip == revision
75
+ return false unless File.read(git_dir_path).strip == git_dir
76
+
77
+ File.directory?(File.join(dest, "v1", "packages")) ||
78
+ File.directory?(File.join(dest, "prayers", "v1", "packages")) ||
79
+ Dir.children(dest).any? { |name| File.directory?(File.join(dest, name, "v1", "packages")) }
80
+ rescue Errno::ENOENT
81
+ false
65
82
  end
66
83
 
67
- def artifact_sparse_cone(repo_relative)
68
- parts = repo_relative.split("/").reject(&:empty?)
69
- return repo_relative if parts.length < 2
84
+ def unpack_archive_prefix(git_dir, dest, revision, prefix)
85
+ env = ENV.to_h.merge("GIT_TERMINAL_PROMPT" => "0")
86
+ stdout, _stderr, status = Open3.capture3(
87
+ env,
88
+ "git",
89
+ "-c",
90
+ "protocol.file.allow=always",
91
+ "-C",
92
+ git_dir,
93
+ "archive",
94
+ "--format=tar",
95
+ revision,
96
+ "--",
97
+ prefix,
98
+ stdin_data: ""
99
+ )
100
+ return false unless status.success? && !stdout.empty?
101
+
102
+ _ignored, extract_status = Open3.capture2(env, "tar", "-x", "-C", dest, stdin_data: stdout)
103
+ raise Error.resolution("failed to unpack git catalog archive") unless extract_status.success?
104
+
105
+ true
106
+ end
70
107
 
71
- parts.pop
72
- parts.pop if parts.last != "artifacts" && parts.length > 3
73
- parts.join("/")
108
+ def find_catalog_markers(start)
109
+ current = start
110
+ 8.times do
111
+ revision_path = File.join(current, REVISION_MARKER)
112
+ git_dir_path = File.join(current, GIT_DIR_MARKER)
113
+ if File.file?(revision_path) && File.file?(git_dir_path)
114
+ revision = File.read(revision_path).strip
115
+ git_dir = File.read(git_dir_path).strip
116
+ return if revision.empty? || !File.exist?(git_dir)
117
+
118
+ return {git_dir: git_dir, revision: revision, work_tree: current}
119
+ end
120
+ parent = File.dirname(current)
121
+ return if parent == current
122
+
123
+ current = parent
124
+ end
125
+ nil
74
126
  end
75
127
  end
76
128
  end