Version 0.3.0: resolver/merge/cache refactor, signing design notes
- Split the monolith into package/provider/fetch/merge/cache modules with the Resolver deciding install/adopt/update/merge/conflict outcomes - Three-way merges via git merge-file/diff3 against a content-addressed merge-base cache; conflicts go to a .picopackage-merge sibling - Tests for package, provider, resolver, merge, and cache - notes.md: update UX and signing design — diff-by-default, SSH signature identity pinning (TOFU), key changes as a hard stop, exit-code contract - Add CLAUDE.md; remove the pre-refactor exe/pppkg monolith and scratch files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EiyJC73Mz8xZyCTvCEY8qn
This commit is contained in:
co-authored by
Claude Fable 5
parent
e0cd0f0d7a
commit
6dd57e84f1
@@ -0,0 +1,340 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class TestResolver < Minitest::Test
|
||||
include PicopackageTest
|
||||
|
||||
V1 = <<~RUBY
|
||||
module Poller
|
||||
INTERVAL = 30
|
||||
|
||||
def self.run
|
||||
fetch
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
|
||||
# Upstream carrying its own block is optional, not required — but when it does,
|
||||
# only some of the block is upstream's to assert. This one is stale, as any
|
||||
# author who edits without re-running the tool will ship.
|
||||
PACKAGED = V1 + <<~RUBY
|
||||
# @PICOPACKAGE_START
|
||||
# ---
|
||||
# url: https://example.com/canonical/poller.rb
|
||||
# filename: upstream_name.rb
|
||||
# payload_version: 1.4.0
|
||||
# payload_checksum: sha256:#{"de" * 32}
|
||||
# licence: MIT
|
||||
# @PICOPACKAGE_END
|
||||
RUBY
|
||||
|
||||
def resolve(upstream_path, project, cache, force: false, filename: nil)
|
||||
Picopackage::Fetch.fetch(upstream_path, project, force: force, filename: filename, cache: cache)
|
||||
end
|
||||
|
||||
def test_installs_when_nothing_is_there
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :installed, result.state
|
||||
installed = Picopackage::Package.from_file(File.join(project, "poller.rb"))
|
||||
refute_predicate installed, :bare?
|
||||
assert_equal path, installed.url
|
||||
assert installed.verify_payload
|
||||
end
|
||||
end
|
||||
|
||||
# A bare upstream file is a valid picopackage source: nobody has to adopt the
|
||||
# format for their code to be installable. Everything the block would have
|
||||
# said gets derived from the fetch instead.
|
||||
def test_installs_from_an_upstream_with_no_metadata_block
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
assert_predicate upstream_package(path), :bare?, "precondition: upstream carries no block"
|
||||
|
||||
assert_equal :installed, resolve(path, project, cache).state
|
||||
installed = Picopackage::Package.from_file(File.join(project, "poller.rb"))
|
||||
assert installed.verify_payload
|
||||
assert_equal path, installed.url
|
||||
end
|
||||
end
|
||||
|
||||
def test_publisher_claims_are_adopted_and_local_records_are_recomputed
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", PACKAGED)
|
||||
|
||||
assert_equal :installed, resolve(path, project, cache).state
|
||||
|
||||
# Claims: upstream's, and worth more than anything we could infer. The
|
||||
# declared filename beats the url's basename, so the file lands as the
|
||||
# author named it rather than as whatever the mirror called it.
|
||||
local_path = File.join(project, "upstream_name.rb")
|
||||
assert_path_exists local_path
|
||||
installed = Picopackage::Package.from_file(local_path)
|
||||
assert_equal "https://example.com/canonical/poller.rb", installed.url,
|
||||
"the canonical url should outlive the mirror we fetched from"
|
||||
assert_equal "1.4.0", installed.payload_version
|
||||
assert_equal "MIT", installed.metadata.extra["licence"]
|
||||
|
||||
# Records: ours, describing this copy on this disk.
|
||||
assert installed.verify_payload, "a freshly installed file must verify, whatever upstream claimed"
|
||||
refute_predicate installed, :modified?
|
||||
refute_predicate installed, :diverged?
|
||||
assert_equal "upstream_name.rb", installed.filename, "filename records where it actually landed"
|
||||
end
|
||||
end
|
||||
|
||||
def test_a_stale_upstream_checksum_does_not_break_a_later_merge
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", PACKAGED)
|
||||
resolve(path, project, cache)
|
||||
local_path = File.join(project, "upstream_name.rb")
|
||||
|
||||
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||
File.write(path, PACKAGED.sub(" fetch\n", " fetch\n prune\n"))
|
||||
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :merged, result.state, result.message
|
||||
merged = File.read(local_path)
|
||||
assert_includes merged, "INTERVAL = 5"
|
||||
assert_includes merged, "prune"
|
||||
assert_equal "MIT", Picopackage::Package.from_file(local_path).metadata.extra["licence"]
|
||||
end
|
||||
end
|
||||
|
||||
def test_reinstalling_the_same_thing_is_a_no_op
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
before = File.read(File.join(project, "poller.rb"))
|
||||
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :current, result.state
|
||||
assert_equal before, File.read(File.join(project, "poller.rb")), "an up-to-date file should not be rewritten"
|
||||
end
|
||||
end
|
||||
|
||||
# The pleasant case: a file you pasted in by hand months ago gets its
|
||||
# provenance attached without its content being touched.
|
||||
def test_adopts_a_bare_local_file_with_identical_content
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
File.write(File.join(project, "poller.rb"), V1)
|
||||
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :adopted, result.state
|
||||
adopted = Picopackage::Package.from_file(File.join(project, "poller.rb"))
|
||||
refute_predicate adopted, :bare?
|
||||
assert_equal Picopackage::Payload.normalize(V1), adopted.payload
|
||||
end
|
||||
end
|
||||
|
||||
def test_fast_forwards_an_unmodified_package
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
|
||||
File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 60"))
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :updated, result.state
|
||||
assert_includes File.read(File.join(project, "poller.rb")), "INTERVAL = 60"
|
||||
end
|
||||
end
|
||||
|
||||
# The headline capability: local edits and upstream edits both survive.
|
||||
def test_merges_local_edits_with_upstream_changes
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
|
||||
local_path = File.join(project, "poller.rb")
|
||||
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5 # tuned for us"))
|
||||
|
||||
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n"))
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :merged, result.state, result.message
|
||||
merged = File.read(local_path)
|
||||
assert_includes merged, "INTERVAL = 5 # tuned for us", "local edit should survive"
|
||||
assert_includes merged, "prune", "upstream change should be applied"
|
||||
refute_includes merged, "<<<<<<<"
|
||||
|
||||
package = Picopackage::Package.from_file(local_path)
|
||||
assert package.verify_payload, "a merged file should verify against its own recorded checksum"
|
||||
refute_equal package.payload_checksum, package.base_checksum,
|
||||
"base_checksum should still point at upstream, not at the merged result"
|
||||
end
|
||||
end
|
||||
|
||||
# A merged file must remain updatable: the base pointer has to advance to the
|
||||
# upstream payload, not to the merge result, or the next merge replays old
|
||||
# upstream changes as conflicts.
|
||||
def test_a_merged_file_can_be_updated_again
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
local_path = File.join(project, "poller.rb")
|
||||
|
||||
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n"))
|
||||
assert_equal :merged, resolve(path, project, cache).state
|
||||
|
||||
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n report\n"))
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :merged, result.state, result.message
|
||||
merged = File.read(local_path)
|
||||
assert_includes merged, "INTERVAL = 5"
|
||||
assert_includes merged, "report"
|
||||
refute_includes merged, "<<<<<<<"
|
||||
end
|
||||
end
|
||||
|
||||
# Editing locally while upstream stands still is the common case, and there is
|
||||
# nothing to merge: upstream is still the ancestor we branched from.
|
||||
def test_local_edits_with_no_upstream_change_are_left_alone
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
local_path = File.join(project, "poller.rb")
|
||||
|
||||
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||
before = File.read(local_path)
|
||||
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :current, result.state, result.message
|
||||
assert_equal before, File.read(local_path), "an edited file with no upstream change must not be rewritten"
|
||||
end
|
||||
end
|
||||
|
||||
# Repopulating the merge base from an unchanged upstream is the whole reason
|
||||
# that branch still touches the cache.
|
||||
def test_an_unchanged_upstream_restores_a_lost_merge_base
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
local_path = File.join(project, "poller.rb")
|
||||
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||
|
||||
# A fresh machine, or a cleared cache: the ancestor is gone.
|
||||
empty = Picopackage::Cache.new(root: File.join(Dir.mktmpdir, "empty"))
|
||||
assert_equal :current, resolve(path, project, empty).state
|
||||
|
||||
# Upstream now moves. The base recovered above makes this mergeable.
|
||||
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n"))
|
||||
result = resolve(path, project, empty)
|
||||
|
||||
assert_equal :merged, result.state, result.message
|
||||
assert_includes File.read(local_path), "INTERVAL = 5"
|
||||
assert_includes File.read(local_path), "prune"
|
||||
end
|
||||
end
|
||||
|
||||
def test_conflicting_edits_leave_the_original_alone
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
local_path = File.join(project, "poller.rb")
|
||||
|
||||
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||
before = File.read(local_path)
|
||||
|
||||
File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 90"))
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :conflict, result.state
|
||||
assert_equal before, File.read(local_path), "the user's file must not be touched"
|
||||
|
||||
markers = local_path + Picopackage::Resolver::MERGE_SUFFIX
|
||||
assert_path_exists markers
|
||||
assert_includes File.read(markers), "<<<<<<<"
|
||||
end
|
||||
end
|
||||
|
||||
def test_force_discards_local_changes
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
local_path = File.join(project, "poller.rb")
|
||||
|
||||
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||
File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 90"))
|
||||
|
||||
result = resolve(path, project, cache, force: true)
|
||||
|
||||
assert_equal :updated, result.state
|
||||
assert_includes File.read(local_path), "INTERVAL = 90"
|
||||
refute_includes File.read(local_path), "INTERVAL = 5"
|
||||
end
|
||||
end
|
||||
|
||||
def test_an_unrelated_bare_file_with_the_same_name_is_refused
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
File.write(File.join(project, "poller.rb"), "# something else entirely\n")
|
||||
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :conflict, result.state
|
||||
assert_equal "# something else entirely\n", File.read(File.join(project, "poller.rb"))
|
||||
end
|
||||
end
|
||||
|
||||
def test_a_modified_file_with_no_cached_ancestor_is_refused
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
local_path = File.join(project, "poller.rb")
|
||||
File.write(local_path, File.read(local_path).sub("INTERVAL = 30", "INTERVAL = 5"))
|
||||
File.write(path, V1.sub(" fetch\n", " fetch\n prune\n"))
|
||||
|
||||
empty_cache = Picopackage::Cache.new(root: File.join(Dir.mktmpdir, "empty"))
|
||||
result = resolve(path, project, empty_cache)
|
||||
|
||||
assert_equal :conflict, result.state
|
||||
assert_match(/cache/, result.message)
|
||||
end
|
||||
end
|
||||
|
||||
# Renaming a package locally is allowed; updating it must not install a second
|
||||
# copy under upstream's preferred name.
|
||||
def test_update_writes_back_to_the_local_filename
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
FileUtils.mv(File.join(project, "poller.rb"), File.join(project, "my_poller.rb"))
|
||||
|
||||
File.write(path, V1.sub("INTERVAL = 30", "INTERVAL = 60"))
|
||||
result = resolve(path, project, cache, filename: "my_poller.rb")
|
||||
|
||||
assert_equal :updated, result.state
|
||||
refute_path_exists File.join(project, "poller.rb"), "should not have created a second copy"
|
||||
assert_includes File.read(File.join(project, "my_poller.rb")), "INTERVAL = 60"
|
||||
assert_equal "my_poller.rb", Picopackage::Package.from_file(File.join(project, "my_poller.rb")).filename
|
||||
end
|
||||
end
|
||||
|
||||
def test_timestamps_never_override_the_checksum
|
||||
with_dirs do |upstream, project, cache|
|
||||
path = publish(upstream, "poller.rb", V1)
|
||||
resolve(path, project, cache)
|
||||
local_path = File.join(project, "poller.rb")
|
||||
before = File.read(local_path)
|
||||
|
||||
# Same bytes, much newer mtime — the old timestamp-driven logic called
|
||||
# this an update and rewrote the file.
|
||||
FileUtils.touch(path, mtime: Time.now + 86_400)
|
||||
result = resolve(path, project, cache)
|
||||
|
||||
assert_equal :current, result.state
|
||||
assert_equal before, File.read(local_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user