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:
Dan Milne
2026-08-13 21:18:19 +10:00
co-authored by Claude Fable 5
parent e0cd0f0d7a
commit 6dd57e84f1
27 changed files with 2010 additions and 383 deletions
+88
View File
@@ -0,0 +1,88 @@
{
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb",
"forks_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/forks",
"commits_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/commits",
"id": "a06926360de8edf108be8591368ce1fb",
"node_id": "G_kwDNRzjaACBhMDY5MjYzNjBkZThlZGYxMDhiZTg1OTEzNjhjZTFmYg",
"git_pull_url": "https://gist.github.com/a06926360de8edf108be8591368ce1fb.git",
"git_push_url": "https://gist.github.com/a06926360de8edf108be8591368ce1fb.git",
"html_url": "https://gist.github.com/dkam/a06926360de8edf108be8591368ce1fb",
"files": {
"ipv6_in_sqlite.rb": {
"filename": "ipv6_in_sqlite.rb",
"type": "application/x-ruby",
"language": "Ruby",
"raw_url": "https://gist.githubusercontent.com/dkam/a06926360de8edf108be8591368ce1fb/raw/13e47fb5d3c981a407997fc954b163fe50a37b45/ipv6_in_sqlite.rb",
"size": 373,
"truncated": false,
"content": "class IpAddress < ApplicationRecord\n def ipv6\n (ipv6_high << 64) | ipv6_low\n end\n\n def ipv6=(addr)\n self.ipv6_high = addr >> 64\n self.ipv6_low = addr & 0xFFFFFFFFFFFFFFFF\n end\n\n def self.ipv6_range(start_ip, end_ip)\n where(ipv6_high: start_ip >> 64..end_ip >> 64)\n .where(ipv6_low: start_ip & 0xFFFFFFFFFFFFFFFF..end_ip & 0xFFFFFFFFFFFFFFFF)\n end\nend",
"encoding": "utf-8"
}
},
"public": false,
"created_at": "2025-01-25T00:07:09Z",
"updated_at": "2025-01-25T00:07:09Z",
"description": "Store IPv6 address in SQLite, via Active Record",
"comments": 0,
"user": null,
"comments_enabled": true,
"comments_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/comments",
"owner": {
"login": "dkam",
"id": 18232,
"node_id": "MDQ6VXNlcjE4MjMy",
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/dkam",
"html_url": "https://github.com/dkam",
"followers_url": "https://api.github.com/users/dkam/followers",
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
"organizations_url": "https://api.github.com/users/dkam/orgs",
"repos_url": "https://api.github.com/users/dkam/repos",
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
"received_events_url": "https://api.github.com/users/dkam/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"forks": [
],
"history": [
{
"user": {
"login": "dkam",
"id": 18232,
"node_id": "MDQ6VXNlcjE4MjMy",
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/dkam",
"html_url": "https://github.com/dkam",
"followers_url": "https://api.github.com/users/dkam/followers",
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
"organizations_url": "https://api.github.com/users/dkam/orgs",
"repos_url": "https://api.github.com/users/dkam/repos",
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
"received_events_url": "https://api.github.com/users/dkam/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"version": "7f52a7373edd4cbf186162dc71444792eece75b3",
"committed_at": "2025-01-25T00:07:09Z",
"change_status": {
"total": 15,
"additions": 15,
"deletions": 0
},
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/7f52a7373edd4cbf186162dc71444792eece75b3"
}
],
"truncated": false
}
+119
View File
@@ -0,0 +1,119 @@
{
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb",
"forks_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/forks",
"commits_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/commits",
"id": "a06926360de8edf108be8591368ce1fb",
"node_id": "G_kwDNRzjaACBhMDY5MjYzNjBkZThlZGYxMDhiZTg1OTEzNjhjZTFmYg",
"git_pull_url": "https://gist.github.com/a06926360de8edf108be8591368ce1fb.git",
"git_push_url": "https://gist.github.com/a06926360de8edf108be8591368ce1fb.git",
"html_url": "https://gist.github.com/dkam/a06926360de8edf108be8591368ce1fb",
"files": {
"ipv6_in_sqlite.rb": {
"filename": "ipv6_in_sqlite.rb",
"type": "application/x-ruby",
"language": "Ruby",
"raw_url": "https://gist.githubusercontent.com/dkam/a06926360de8edf108be8591368ce1fb/raw/458a32b55c57e42d6e541f29317e9445a87d4e43/ipv6_in_sqlite.rb",
"size": 384,
"truncated": false,
"content": "# Comments\nclass IpAddress < ApplicationRecord\n def ipv6\n (ipv6_high << 64) | ipv6_low\n end\n\n def ipv6=(addr)\n self.ipv6_high = addr >> 64\n self.ipv6_low = addr & 0xFFFFFFFFFFFFFFFF\n end\n\n def self.ipv6_range(start_ip, end_ip)\n where(ipv6_high: start_ip >> 64..end_ip >> 64)\n .where(ipv6_low: start_ip & 0xFFFFFFFFFFFFFFFF..end_ip & 0xFFFFFFFFFFFFFFFF)\n end\nend",
"encoding": "utf-8"
}
},
"public": false,
"created_at": "2025-01-25T00:07:09Z",
"updated_at": "2025-01-25T00:08:48Z",
"description": "Store IPv6 address in SQLite, via Active Record",
"comments": 0,
"user": null,
"comments_enabled": true,
"comments_url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/comments",
"owner": {
"login": "dkam",
"id": 18232,
"node_id": "MDQ6VXNlcjE4MjMy",
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/dkam",
"html_url": "https://github.com/dkam",
"followers_url": "https://api.github.com/users/dkam/followers",
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
"organizations_url": "https://api.github.com/users/dkam/orgs",
"repos_url": "https://api.github.com/users/dkam/repos",
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
"received_events_url": "https://api.github.com/users/dkam/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"forks": [
],
"history": [
{
"user": {
"login": "dkam",
"id": 18232,
"node_id": "MDQ6VXNlcjE4MjMy",
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/dkam",
"html_url": "https://github.com/dkam",
"followers_url": "https://api.github.com/users/dkam/followers",
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
"organizations_url": "https://api.github.com/users/dkam/orgs",
"repos_url": "https://api.github.com/users/dkam/repos",
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
"received_events_url": "https://api.github.com/users/dkam/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"version": "9774aa95cbf7c1fa7bf2ea27b2cb66898d0769c8",
"committed_at": "2025-01-25T00:08:48Z",
"change_status": {
"total": 1,
"additions": 1,
"deletions": 0
},
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/9774aa95cbf7c1fa7bf2ea27b2cb66898d0769c8"
},
{
"user": {
"login": "dkam",
"id": 18232,
"node_id": "MDQ6VXNlcjE4MjMy",
"avatar_url": "https://avatars.githubusercontent.com/u/18232?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/dkam",
"html_url": "https://github.com/dkam",
"followers_url": "https://api.github.com/users/dkam/followers",
"following_url": "https://api.github.com/users/dkam/following{/other_user}",
"gists_url": "https://api.github.com/users/dkam/gists{/gist_id}",
"starred_url": "https://api.github.com/users/dkam/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/dkam/subscriptions",
"organizations_url": "https://api.github.com/users/dkam/orgs",
"repos_url": "https://api.github.com/users/dkam/repos",
"events_url": "https://api.github.com/users/dkam/events{/privacy}",
"received_events_url": "https://api.github.com/users/dkam/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"version": "7f52a7373edd4cbf186162dc71444792eece75b3",
"committed_at": "2025-01-25T00:07:09Z",
"change_status": {
"total": 15,
"additions": 15,
"deletions": 0
},
"url": "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb/7f52a7373edd4cbf186162dc71444792eece75b3"
}
],
"truncated": false
}
+18
View File
@@ -0,0 +1,18 @@
# Make an array of hashes contain a unique value for key:
def uniquify_array(array, key)
array.group_by { |item| item[key] }.map do |_, items|
(items.length > 1) ? yield(items) : items.first
end
end
# new_array = uniquify_array(array, :some_key) do |dupes|
# dupes.max_by { |item| item[:updated_at] }
# end
# @PICOPACKAGE_START
# ---
# filename: uniquify_array.rb
# url: https://api.github.com/gists/525ac4177964549ee2f2ca7febd03eea
# version: 0.1
# source_url: https://gist.github.com/dkam/525ac4177964549ee2f2ca7febd03eea
# @PICOPACKAGE_END
+36
View File
@@ -0,0 +1,36 @@
# frozen_string_literal: true
require "test_helper"
class TestCache < Minitest::Test
def with_cache
Dir.mktmpdir("picopackage-cache") { |root| yield Picopackage::Cache.new(root: root) }
end
def test_stores_and_fetches_by_content
with_cache do |cache|
checksum = cache.store("hello\n")
assert_equal Picopackage::Payload.checksum("hello\n"), checksum
assert_equal "hello\n", cache.fetch(checksum)
end
end
def test_storing_twice_is_harmless
with_cache do |cache|
a = cache.store("hello\n")
b = cache.store("hello\n")
assert_equal a, b
assert_equal "hello\n", cache.fetch(a)
end
end
def test_unknown_and_malformed_checksums_return_nil
with_cache do |cache|
assert_nil cache.fetch(Picopackage::Payload.checksum("never stored"))
assert_nil cache.fetch(nil)
assert_nil cache.fetch("")
assert_nil cache.fetch("sha256:../../escape")
refute cache.include?(nil)
end
end
end
+26
View File
@@ -4,3 +4,29 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
require "picopackage"
require "minitest/autorun"
require "tmpdir"
module PicopackageTest
# Every test gets its own cache root: the merge base cache is global state and
# a suite that shared one would pass or fail depending on what ran before it.
def with_dirs
Dir.mktmpdir("picopackage-test") do |root|
upstream = File.join(root, "upstream")
project = File.join(root, "project")
[upstream, project].each { |dir| Dir.mkdir(dir) }
yield(upstream, project, Picopackage::Cache.new(root: File.join(root, "cache")))
end
end
# Stands in for a server: a real file, fetched through the real FileProvider,
# so provider metadata population is exercised rather than faked.
def publish(dir, name, content)
path = File.join(dir, name)
File.write(path, content)
path
end
def upstream_package(path)
Picopackage::FileProvider.new(path).package
end
end
+55
View File
@@ -0,0 +1,55 @@
# frozen_string_literal: true
require "test_helper"
class TestMerge < Minitest::Test
BASE = "one\ntwo\nthree\n"
def test_a_backend_is_available
assert Picopackage::Merge.available?, "expected git or diff3 on PATH"
end
def test_non_overlapping_changes_merge_cleanly
result = Picopackage::Merge.three_way(
base: BASE,
ours: "ONE\ntwo\nthree\n",
theirs: "one\ntwo\nTHREE\n"
)
assert_predicate result, :clean?
assert_equal "ONE\ntwo\nTHREE\n", result.content
end
def test_identical_changes_on_both_sides_merge_cleanly
changed = "one\nTWO\nthree\n"
result = Picopackage::Merge.three_way(base: BASE, ours: changed, theirs: changed)
assert_predicate result, :clean?
assert_equal changed, result.content
end
def test_overlapping_changes_conflict
result = Picopackage::Merge.three_way(
base: BASE,
ours: "one\nMINE\nthree\n",
theirs: "one\nTHEIRS\nthree\n"
)
assert_predicate result, :conflict?
assert_operator result.conflicts, :>, 0
assert_includes result.content, "MINE"
assert_includes result.content, "THEIRS"
assert_includes result.content, "<<<<<<<"
end
def test_labels_appear_in_conflict_markers
result = Picopackage::Merge.three_way(
base: BASE,
ours: "one\nMINE\nthree\n",
theirs: "one\nTHEIRS\nthree\n"
)
assert_includes result.content, "local"
assert_includes result.content, "upstream"
end
end
+106
View File
@@ -0,0 +1,106 @@
# frozen_string_literal: true
require "test_helper"
class TestPackage < Minitest::Test
include PicopackageTest
BARE = "def hello\n :world\nend\n"
def test_it_has_a_version_number
refute_nil ::Picopackage::VERSION
end
def test_a_bare_file_is_bare
assert Picopackage::Package.new(content: BARE).bare?
end
def test_a_generated_package_parses_back_as_a_package
package = Picopackage::Package.new(content: BARE).init_metadata(filename: "hello.rb")
round_tripped = Picopackage::Package.new(content: package.generate_package)
refute_predicate round_tripped, :bare?
assert_equal "hello.rb", round_tripped.filename
end
def test_reads_metadata_from_a_packaged_file
package = Picopackage::Package.from_file("test/files/uniquify_array_packaged.rb")
assert_equal "uniquify_array.rb", package.filename
assert_equal "https://api.github.com/gists/525ac4177964549ee2f2ca7febd03eea", package.url
end
# This was the bug that made every file look locally modified: the checksum
# written into a package hashed the un-normalised payload while verification
# hashed the normalised one, so nothing ever verified against itself.
def test_a_freshly_packaged_file_verifies_against_its_own_checksum
package = Picopackage::Package.new(content: BARE).init_metadata(filename: "hello.rb")
written = Picopackage::Package.new(content: package.generate_package)
assert written.verify_payload, "a package should satisfy its own checksum"
refute_predicate written, :modified?
end
def test_payload_digest_is_stable_across_packaging
bare = Picopackage::Package.new(content: BARE)
packaged = Picopackage::Package.new(
content: Picopackage::Package.new(content: BARE).init_metadata(filename: "hello.rb").generate_package
)
assert_equal bare.payload_digest, packaged.payload_digest,
"adding a metadata block must not change the payload digest"
end
def test_editing_a_payload_is_detected
packaged = Picopackage::Package.new(content: BARE).init_metadata(filename: "hello.rb").generate_package
edited = Picopackage::Package.new(content: packaged.sub(":world", ":everyone"))
assert_predicate edited, :modified?
end
def test_unmodelled_metadata_keys_survive_a_rewrite
content = <<~RUBY
code_here
# @PICOPACKAGE_START
# ---
# filename: thing.rb
# licence: MIT
# test_url: https://example.com/thing_test.rb
# @PICOPACKAGE_END
RUBY
package = Picopackage::Package.new(content: content)
assert_equal "MIT", package.metadata.extra["licence"]
rewritten = Picopackage::Package.new(content: package.generate_package)
assert_equal "MIT", rewritten.metadata.extra["licence"]
assert_equal "https://example.com/thing_test.rb", rewritten.metadata.extra["test_url"]
end
# A checksum is derived, never preserved. An author who edits a packaged file
# by hand and re-runs the tool must get a block describing what is now there,
# not the value that happened to already be sitting in it.
def test_a_stale_recorded_checksum_is_corrected_on_write
content = "code\n\n# @PICOPACKAGE_START\n# ---\n# filename: thing.rb\n" \
"# payload_checksum: sha256:#{"de" * 32}\n# @PICOPACKAGE_END\n"
stale = Picopackage::Package.new(content: content)
refute stale.verify_payload, "precondition: the recorded checksum is wrong"
rewritten = Picopackage::Package.new(content: stale.generate_package)
assert rewritten.verify_payload
refute_predicate rewritten, :modified?
end
def test_a_block_of_only_unmodelled_keys_is_not_treated_as_bare
content = "code\n\n# @PICOPACKAGE_START\n# ---\n# licence: MIT\n# @PICOPACKAGE_END\n"
refute_predicate Picopackage::Package.new(content: content), :bare?,
"a block we don't fully understand is still a block"
end
def test_malformed_front_matter_does_not_raise
package = Picopackage::Package.from_file("test/files/broken_front_matter_1.rb")
refute_nil package
assert_predicate package.metadata, :empty?
end
end
-19
View File
@@ -1,19 +0,0 @@
# frozen_string_literal: true
require "test_helper"
class TestPicopackage < Minitest::Test
def test_that_it_has_a_version_number
refute_nil ::Picopackage::VERSION
end
def test_it_can_load_a_picopackage_file
sf = Picopackage::FileProvider.new(File.read("test/files/uniquify_array_packaged.rb"))
assert_equal "uniquify_array_packaged.rb", sf.metadata.filename
end
def test_it_can_create_a_picopackage_from_bare_file
sf = Picopackage::FileProvider.new(File.read("test/files/uniquify_array_bare.rb"))
assert_equal "uniquify_array_bare.rb", sf.metadata.filename
end
end
+69
View File
@@ -0,0 +1,69 @@
# frozen_string_literal: true
require "test_helper"
# Two captured responses for the same gist, one revision apart: v2 prepends a
# `# Comments` line and carries a later `updated_at`. Real API shapes, no
# network, and enough of a difference to drive an update through the resolver.
class TestGithubGistProvider < Minitest::Test
include PicopackageTest
GIST_URL = "https://gist.github.com/dkam/a06926360de8edf108be8591368ce1fb"
StubFetcher = Struct.new(:body) do
def fetch(_uri) = body
end
def gist(revision)
Picopackage::GithubGistProvider.new(
GIST_URL, fetcher: StubFetcher.new(File.read("test/files/gist_#{revision}.json"))
)
end
def test_resolves_the_page_url_to_the_api
assert_equal "https://api.github.com/gists/a06926360de8edf108be8591368ce1fb",
gist("v1").url.to_s
end
def test_extracts_the_file_from_the_json_envelope
package = gist("v1").package
assert_equal "ipv6_in_sqlite.rb", package.filename
assert_includes package.payload, "def ipv6"
refute_includes package.payload, "raw_url", "the JSON envelope must not leak into the payload"
assert_predicate package, :bare?, "the gist carries no metadata block of its own"
end
# The gist's own revision time, not the moment we happened to fetch it.
def test_uses_the_gists_updated_at_as_the_timestamp
assert_equal "2025-01-25T00:07:09Z", gist("v1").package.payload_timestamp
assert_equal "2025-01-25T00:08:48Z", gist("v2").package.payload_timestamp
end
def test_a_new_revision_is_a_different_payload
refute_equal gist("v1").package.payload_digest, gist("v2").package.payload_digest
end
# End to end against a real upstream shape: install v1, edit it, take v2.
def test_updating_across_revisions_keeps_local_edits
with_dirs do |_upstream, project, cache|
local_path = File.join(project, "ipv6_in_sqlite.rb")
assert_equal :installed, resolve(gist("v1").package, nil, local_path, cache).state
File.write(local_path, File.read(local_path).sub("where(ipv6_high:", "where!(ipv6_high:"))
result = resolve(gist("v2").package, Picopackage::Package.from_file(local_path), local_path, cache)
assert_equal :merged, result.state, result.message
merged = File.read(local_path)
assert_includes merged, "where!(ipv6_high:", "local edit should survive"
assert_includes merged, "# Comments", "the new revision should be applied"
refute_includes merged, "<<<<<<<"
end
end
private
def resolve(remote, local, path, cache)
Picopackage::Resolver.new(remote, local, path, cache: cache).resolve
end
end
+340
View File
@@ -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