textus 0.40.0 → 0.41.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 +4 -4
- data/CHANGELOG.md +13 -1
- data/SPEC.md +16 -2
- data/docs/architecture/README.md +256 -0
- data/docs/reference/conventions.md +148 -0
- data/lib/textus/cli/verb/hook_run.rb +3 -1
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/doctor/check/publish_tree_index_overlap.rb +48 -0
- data/lib/textus/doctor.rb +1 -0
- data/lib/textus/manifest/entry/base.rb +12 -20
- data/lib/textus/manifest/entry/nested.rb +8 -89
- data/lib/textus/manifest/entry/publish/each.rb +83 -0
- data/lib/textus/manifest/entry/publish/each_dir.rb +74 -0
- data/lib/textus/manifest/entry/publish/each_file.rb +29 -0
- data/lib/textus/manifest/entry/publish/mode.rb +39 -0
- data/lib/textus/manifest/entry/publish/none.rb +14 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +72 -0
- data/lib/textus/manifest/entry/publish/template.rb +22 -0
- data/lib/textus/manifest/entry/publish/to_paths.rb +27 -0
- data/lib/textus/manifest/entry/publish/tree.rb +54 -0
- data/lib/textus/manifest/entry/publish.rb +45 -0
- data/lib/textus/manifest/entry/validators/publish.rb +26 -0
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/ports/publisher.rb +14 -2
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/fetch_events.rb +42 -0
- data/lib/textus/write/fetch_orchestrator.rb +2 -3
- data/lib/textus/write/fetch_worker.rb +13 -22
- data/lib/textus/write/intake_fetch.rb +8 -6
- metadata +16 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +0 -79
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
|
+
# A directory entry: enumerates a tree of leaves and resolves to one
|
|
5
|
+
# publish mode (ADR 0049). The publish algorithms themselves live in
|
|
6
|
+
# Entry::Publish::* — Nested is just the value (attributes + ignore
|
|
7
|
+
# predicate) those modes read.
|
|
4
8
|
class Nested < Base
|
|
5
|
-
|
|
6
|
-
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
9
|
+
attr_reader :index_filename, :publish_each, :publish_tree, :ignore
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def initialize(index_filename: nil, publish_each: nil, ignore: nil, **rest)
|
|
11
|
+
def initialize(index_filename: nil, publish_each: nil, publish_tree: nil, ignore: nil, **rest)
|
|
11
12
|
super(**rest)
|
|
12
13
|
@index_filename = index_filename
|
|
13
14
|
@publish_each = publish_each
|
|
15
|
+
@publish_tree = publish_tree
|
|
14
16
|
@ignore = Array(ignore)
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -21,96 +23,13 @@ module Textus
|
|
|
21
23
|
# an ignored path is excluded, never judged.
|
|
22
24
|
def ignored?(rel_path) = IgnoreMatcher.match?(@ignore, rel_path)
|
|
23
25
|
|
|
24
|
-
def publish_target_for(full_key)
|
|
25
|
-
return nil if @publish_each.nil?
|
|
26
|
-
|
|
27
|
-
entry_segs = @key.split(".")
|
|
28
|
-
key_segs = full_key.split(".")
|
|
29
|
-
raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
30
|
-
|
|
31
|
-
remaining = key_segs[entry_segs.length..] || []
|
|
32
|
-
leaf = remaining.join("/")
|
|
33
|
-
basename = remaining.last || ""
|
|
34
|
-
ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
|
|
35
|
-
|
|
36
|
-
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
37
|
-
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def publish_via(pctx, prefix: nil)
|
|
41
|
-
return nil if @publish_each.nil?
|
|
42
|
-
|
|
43
|
-
leaves = []
|
|
44
|
-
pruned = [] # accumulates orphans removed by prune_orphans below
|
|
45
|
-
pctx.manifest.resolver.enumerate(prefix: @key).each do |row|
|
|
46
|
-
next unless row[:manifest_entry].equal?(self)
|
|
47
|
-
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
48
|
-
|
|
49
|
-
target_rel = publish_target_for(row[:key])
|
|
50
|
-
target_abs = File.expand_path(File.join(pctx.repo_root, target_rel))
|
|
51
|
-
unless target_abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
|
|
52
|
-
raise Textus::PublishError.new(
|
|
53
|
-
"entry '#{@key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
written = @index_filename ? publish_subtree(row, target_abs, pctx) : [publish_one(row, target_abs, pctx)]
|
|
58
|
-
pruned.concat(prune_orphans(target_abs, written, pctx)) if @index_filename
|
|
59
|
-
written.each { |w| leaves << { "key" => row[:key], "source" => w["source"], "target" => w["target"] } }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
{ kind: :leaves, value: leaves, pruned: pruned }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def publish_one(row, target_abs, pctx)
|
|
66
|
-
Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
|
|
67
|
-
pctx.emit(:file_published, key: row[:key], envelope: pctx.reader.call(row[:key]),
|
|
68
|
-
source: row[:path], target: target_abs)
|
|
69
|
-
{ "source" => row[:path], "target" => target_abs }
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def publish_subtree(row, target_dir, pctx)
|
|
73
|
-
base = File.join(pctx.root, "zones", path)
|
|
74
|
-
leaf_dir = File.dirname(row[:path])
|
|
75
|
-
# FNM_DOTMATCH includes dotfiles; File.file? below skips dirs (and symlinks-to-dirs). Leaf trees are authored content, not arbitrary symlink graphs.
|
|
76
|
-
Dir.glob(File.join(leaf_dir, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |src|
|
|
77
|
-
next nil unless File.file?(src)
|
|
78
|
-
|
|
79
|
-
rel_to_base = src.sub(%r{\A#{Regexp.escape(base)}/}, "")
|
|
80
|
-
next nil if ignored?(rel_to_base)
|
|
81
|
-
|
|
82
|
-
rel_to_leaf = src.sub(%r{\A#{Regexp.escape(leaf_dir)}/}, "")
|
|
83
|
-
dst = File.join(target_dir, rel_to_leaf)
|
|
84
|
-
Textus::Ports::Publisher.publish(source: src, target: dst, store_root: pctx.root)
|
|
85
|
-
pctx.emit(:file_published, key: row[:key], envelope: pctx.reader.call(row[:key]),
|
|
86
|
-
source: src, target: dst)
|
|
87
|
-
{ "source" => src, "target" => dst }
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Scoped to this leaf's target_dir only. Safe across leaves because ADR 0046
|
|
92
|
-
# Decision 5 (shallowest-index-wins) keeps leaf dirs non-nesting, so {leaf}-derived
|
|
93
|
-
# target dirs never nest and targets_under can't reach another leaf's sentinels.
|
|
94
|
-
def prune_orphans(target_dir, written, pctx)
|
|
95
|
-
kept = written.map { |w| File.expand_path(w["target"]) }
|
|
96
|
-
store = Textus::Ports::SentinelStore.new
|
|
97
|
-
store.targets_under(target_dir, pctx.root).filter_map do |managed|
|
|
98
|
-
next nil if kept.include?(File.expand_path(managed))
|
|
99
|
-
|
|
100
|
-
Textus::Ports::Publisher.unpublish(target: managed, store_root: pctx.root)
|
|
101
|
-
managed
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Helpers are private; KIND / self.from_raw / REGISTRY below are intentionally public.
|
|
106
|
-
private :publish_one, :publish_subtree, :prune_orphans
|
|
107
|
-
|
|
108
26
|
KIND = :nested
|
|
109
27
|
|
|
110
28
|
def self.from_raw(common, raw)
|
|
111
29
|
new(
|
|
112
30
|
index_filename: raw["index_filename"],
|
|
113
31
|
publish_each: raw["publish_each"],
|
|
32
|
+
publish_tree: raw["publish_tree"],
|
|
114
33
|
ignore: raw["ignore"],
|
|
115
34
|
**common,
|
|
116
35
|
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# Shared base for the two key-driven publish_each modes (EachFile,
|
|
6
|
+
# EachDir). Owns the leaf enumeration, the `{...}` target templating, and
|
|
7
|
+
# the per-leaf repo-escape guard. Subclasses implement `#publish_leaf`,
|
|
8
|
+
# returning `{ written:, pruned: }`, and the discriminator half of
|
|
9
|
+
# `#validate!`.
|
|
10
|
+
class Each < Mode
|
|
11
|
+
def publish(pctx, prefix: nil)
|
|
12
|
+
leaves = []
|
|
13
|
+
pruned = []
|
|
14
|
+
pctx.manifest.resolver.enumerate(prefix: entry.key).each do |row|
|
|
15
|
+
next unless row[:manifest_entry].equal?(entry)
|
|
16
|
+
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
17
|
+
|
|
18
|
+
target_abs = guarded_target(pctx, row)
|
|
19
|
+
result = publish_leaf(row, target_abs, pctx)
|
|
20
|
+
pruned.concat(result[:pruned])
|
|
21
|
+
result[:written].each do |w|
|
|
22
|
+
leaves << { "key" => row[:key], "source" => w["source"], "target" => w["target"] }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{ kind: :leaves, value: leaves, pruned: pruned }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Expand this entry's publish_each template for a full leaf key.
|
|
30
|
+
def target_for(full_key)
|
|
31
|
+
entry_segs = entry.key.split(".")
|
|
32
|
+
key_segs = full_key.split(".")
|
|
33
|
+
raise UsageError.new("key '#{full_key}' is not under entry '#{entry.key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
34
|
+
|
|
35
|
+
remaining = key_segs[entry_segs.length..] || []
|
|
36
|
+
Template.expand(
|
|
37
|
+
entry.publish_each,
|
|
38
|
+
"leaf" => remaining.join("/"),
|
|
39
|
+
"basename" => remaining.last || "",
|
|
40
|
+
"key" => full_key,
|
|
41
|
+
"ext" => ext,
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def guarded_target(pctx, row)
|
|
48
|
+
target_rel = target_for(row[:key])
|
|
49
|
+
target_abs = repo_abs(pctx, target_rel)
|
|
50
|
+
return target_abs if inside_repo?(pctx, target_abs)
|
|
51
|
+
|
|
52
|
+
raise Textus::PublishError.new(
|
|
53
|
+
"entry '#{entry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ext
|
|
58
|
+
Textus::Entry.for_format(entry.format).extensions.first.to_s.sub(/^\./, "")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# publish_each shape rules common to file and directory leaves: a
|
|
62
|
+
# String value with only known template vars. Returns the used vars so
|
|
63
|
+
# subclasses can apply their discriminator rule.
|
|
64
|
+
def validate_template_basics
|
|
65
|
+
publish_each = entry.publish_each
|
|
66
|
+
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
|
|
67
|
+
|
|
68
|
+
used_vars = publish_each.scan(Template::VAR_RE).flatten
|
|
69
|
+
unknown = used_vars - Template::KNOWN_VARS
|
|
70
|
+
unless unknown.empty?
|
|
71
|
+
raise UsageError.new(
|
|
72
|
+
"entry '#{entry.key}': publish_each uses unknown template variable(s) " \
|
|
73
|
+
"#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{Template::KNOWN_VARS.map { |v| "{#{v}}" }.join(", ")}.",
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
used_vars
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# publish_each + index_filename (ADR 0046): each leaf is a whole subtree
|
|
6
|
+
# copied into one templated directory, layout preserved, then pruned.
|
|
7
|
+
# The template names the target DIRECTORY (not the index file).
|
|
8
|
+
class EachDir < Each
|
|
9
|
+
def publish_leaf(row, target_abs, pctx)
|
|
10
|
+
SubtreeMirror.new(entry, pctx).mirror(
|
|
11
|
+
base: store_base(pctx),
|
|
12
|
+
walk_root: File.dirname(row[:path]),
|
|
13
|
+
target_dir: target_abs,
|
|
14
|
+
key: row[:key],
|
|
15
|
+
envelope: pctx.reader.call(row[:key]),
|
|
16
|
+
prune_honors_ignore: false,
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate!
|
|
21
|
+
used_vars = validate_template_basics
|
|
22
|
+
reject_file_only_vars(used_vars)
|
|
23
|
+
reject_index_filename_segment
|
|
24
|
+
reject_file_looking_segment
|
|
25
|
+
return if used_vars.intersect?(%w[leaf key])
|
|
26
|
+
|
|
27
|
+
raise UsageError.new(
|
|
28
|
+
"entry '#{entry.key}': directory-leaf publish_each must reference {leaf} or {key} " \
|
|
29
|
+
"(else every leaf would clobber the same directory).",
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def reject_file_only_vars(used_vars)
|
|
36
|
+
forbidden = used_vars & %w[basename ext]
|
|
37
|
+
return if forbidden.empty?
|
|
38
|
+
|
|
39
|
+
raise UsageError.new(
|
|
40
|
+
"entry '#{entry.key}': publish_each names a directory " \
|
|
41
|
+
"(index_filename: '#{entry.index_filename}'); {basename}/{ext} are file-only — " \
|
|
42
|
+
"use {leaf} or {key}.",
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reject_index_filename_segment
|
|
47
|
+
return unless last_segment == entry.index_filename
|
|
48
|
+
|
|
49
|
+
raise UsageError.new(
|
|
50
|
+
"entry '#{entry.key}': directory-leaf publish_each must name the target DIRECTORY, " \
|
|
51
|
+
"not the index file — drop the trailing '/#{entry.index_filename}' " \
|
|
52
|
+
"(the whole leaf subtree is copied into the named directory).",
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reject_file_looking_segment
|
|
57
|
+
ext = File.extname(last_segment)
|
|
58
|
+
return if ext.empty?
|
|
59
|
+
|
|
60
|
+
raise UsageError.new(
|
|
61
|
+
"entry '#{entry.key}': directory-leaf publish_each names a DIRECTORY target, but its " \
|
|
62
|
+
"final segment '#{last_segment}' looks like a file (extension '#{ext}') — " \
|
|
63
|
+
"drop the extension (the whole leaf subtree is copied into the named directory).",
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def last_segment
|
|
68
|
+
entry.publish_each.sub(%r{/\z}, "").split("/").last
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# publish_each over file leaves (no index_filename): one stored leaf file
|
|
6
|
+
# copied to one templated repo path. No prune — each leaf is a single
|
|
7
|
+
# file, not a subtree.
|
|
8
|
+
class EachFile < Each
|
|
9
|
+
def publish_leaf(row, target_abs, pctx)
|
|
10
|
+
Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
|
|
11
|
+
pctx.emit(:file_published, key: row[:key], envelope: pctx.reader.call(row[:key]),
|
|
12
|
+
source: row[:path], target: target_abs)
|
|
13
|
+
{ written: [{ "source" => row[:path], "target" => target_abs }], pruned: [] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate!
|
|
17
|
+
used_vars = validate_template_basics
|
|
18
|
+
return if used_vars.intersect?(Template::REQUIRED_DISCRIMINATOR_VARS)
|
|
19
|
+
|
|
20
|
+
raise UsageError.new(
|
|
21
|
+
"entry '#{entry.key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
|
|
22
|
+
"(else every leaf would clobber the same target).",
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# Base for every publish mode: wraps the resolved entry and owns the one
|
|
6
|
+
# repo-root escape guard the writing modes share (ADR 0049). Subclasses
|
|
7
|
+
# implement `#publish(pctx, prefix:)` returning the existing
|
|
8
|
+
# `{ kind:, value:, pruned: }` shape (or nil), and `#validate!` for the
|
|
9
|
+
# per-mode shape rules reached *because* this mode resolved.
|
|
10
|
+
class Mode
|
|
11
|
+
def initialize(entry)
|
|
12
|
+
@entry = entry
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :entry
|
|
16
|
+
|
|
17
|
+
# No shape rules by default — ToPaths/None publish without templating.
|
|
18
|
+
def validate!; end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Expand `rel` under repo_root and confirm it stays inside it.
|
|
23
|
+
def repo_abs(pctx, rel)
|
|
24
|
+
File.expand_path(File.join(pctx.repo_root, rel))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def inside_repo?(pctx, abs)
|
|
28
|
+
abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Store-side directory this entry's tree lives under.
|
|
32
|
+
def store_base(pctx)
|
|
33
|
+
File.join(pctx.root, "zones", entry.path)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# An entry with no publish_* key — nothing to publish.
|
|
6
|
+
class None < Mode
|
|
7
|
+
def publish(_pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
8
|
+
nil
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# ADR 0049: the one walk->publish->prune pipeline shared by EachDir
|
|
6
|
+
# (per-leaf subtree, ADR 0046) and Tree (whole-entry mirror, ADR 0047).
|
|
7
|
+
# The two used to be near-duplicate methods (publish_subtree +
|
|
8
|
+
# publish_tree_via, prune_orphans + prune_tree); their only real
|
|
9
|
+
# difference — whether the prune honors the entry's `ignore` — is now the
|
|
10
|
+
# explicit `prune_honors_ignore:` parameter.
|
|
11
|
+
class SubtreeMirror
|
|
12
|
+
def initialize(entry, pctx)
|
|
13
|
+
@entry = entry
|
|
14
|
+
@pctx = pctx
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# base: store dir the entry owns — the root `ignored?` globs are
|
|
18
|
+
# relative to (ADR 0042).
|
|
19
|
+
# walk_root: dir the glob is rooted at (a single leaf dir for EachDir,
|
|
20
|
+
# == base for Tree). dst paths mirror rel-to-walk_root.
|
|
21
|
+
# target_dir: repo-side destination root.
|
|
22
|
+
# key/envelope: emitted per file; envelope is nil for the keyless Tree.
|
|
23
|
+
# prune_honors_ignore: when true a managed file the entry `ignore`s
|
|
24
|
+
# survives the prune (ADR 0047 D4 — lets a derived index live in the
|
|
25
|
+
# mirrored dir); when false every unwritten managed file is pruned.
|
|
26
|
+
def mirror(base:, walk_root:, target_dir:, key:, envelope:, prune_honors_ignore:)
|
|
27
|
+
return { written: [], pruned: [] } unless File.directory?(walk_root)
|
|
28
|
+
|
|
29
|
+
written = publish_files(base: base, walk_root: walk_root, target_dir: target_dir, key: key, envelope: envelope)
|
|
30
|
+
{ written: written, pruned: prune(target_dir, written, prune_honors_ignore) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def publish_files(base:, walk_root:, target_dir:, key:, envelope:)
|
|
36
|
+
# FNM_DOTMATCH includes dotfiles; File.file? below skips dirs (and
|
|
37
|
+
# symlinks-to-dirs). Trees are authored content, not symlink graphs.
|
|
38
|
+
Dir.glob(File.join(walk_root, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |src|
|
|
39
|
+
next nil unless File.file?(src)
|
|
40
|
+
next nil if @entry.ignored?(relative(src, base))
|
|
41
|
+
|
|
42
|
+
dst = File.join(target_dir, relative(src, walk_root))
|
|
43
|
+
Textus::Ports::Publisher.publish(source: src, target: dst, store_root: @pctx.root)
|
|
44
|
+
@pctx.emit(:file_published, key: key, envelope: envelope, source: src, target: dst)
|
|
45
|
+
{ "key" => key, "source" => src, "target" => dst }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Scoped to target_dir. Safe across leaves because ADR 0046 D5
|
|
50
|
+
# (shallowest-index-wins) keeps leaf target dirs non-nesting, so
|
|
51
|
+
# targets_under can't reach another leaf's sentinels.
|
|
52
|
+
def prune(target_dir, written, honor_ignore)
|
|
53
|
+
kept = written.map { |w| File.expand_path(w["target"]) }
|
|
54
|
+
store = Textus::Ports::SentinelStore.new
|
|
55
|
+
store.targets_under(target_dir, @pctx.root).filter_map do |managed|
|
|
56
|
+
abs = File.expand_path(managed)
|
|
57
|
+
next nil if kept.include?(abs)
|
|
58
|
+
next nil if honor_ignore && @entry.ignored?(relative(abs, target_dir))
|
|
59
|
+
|
|
60
|
+
Textus::Ports::Publisher.unpublish(target: managed, store_root: @pctx.root)
|
|
61
|
+
managed
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def relative(path, root)
|
|
66
|
+
path.sub(%r{\A#{Regexp.escape(root)}/}, "")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# The publish_each template vocabulary. A publish_each value is a path
|
|
6
|
+
# with `{leaf}`, `{basename}`, `{key}`, `{ext}` placeholders; a
|
|
7
|
+
# publish_tree value must be a plain path (any var is an error), so the
|
|
8
|
+
# modes reuse VAR_RE to detect stray vars there too.
|
|
9
|
+
module Template
|
|
10
|
+
KNOWN_VARS = %w[leaf basename key ext].freeze
|
|
11
|
+
VAR_RE = /\{([a-z]+)\}/
|
|
12
|
+
REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
|
|
13
|
+
|
|
14
|
+
# Substitute `{var}` placeholders from a string-keyed hash.
|
|
15
|
+
def self.expand(template, vars)
|
|
16
|
+
template.gsub(VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# publish_to: copy the entry's one stored file to each fixed repo path.
|
|
6
|
+
# The default behaviour of any entry that declares `publish_to:`.
|
|
7
|
+
class ToPaths < Mode
|
|
8
|
+
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
9
|
+
targets = Array(entry.publish_to)
|
|
10
|
+
return nil if targets.empty?
|
|
11
|
+
|
|
12
|
+
source_path = pctx.manifest.resolver.resolve(entry.key).path
|
|
13
|
+
envelope = pctx.reader.call(entry.key)
|
|
14
|
+
|
|
15
|
+
targets.each do |rel|
|
|
16
|
+
target_abs = File.join(pctx.repo_root, rel)
|
|
17
|
+
Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
|
|
18
|
+
pctx.emit(:file_published, key: entry.key, envelope: envelope, source: source_path, target: target_abs)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
{ kind: :built, value: { "key" => entry.key, "path" => source_path, "published_to" => targets } }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# publish_tree (ADR 0047): mirror this entry's whole subtree to one
|
|
6
|
+
# target dir by real path. No resolver, no keys — files are opaque
|
|
7
|
+
# payload (envelope nil). The prune honors `ignore` so a derived index
|
|
8
|
+
# (e.g. a SKILL.md written by a separate entry into the same dir)
|
|
9
|
+
# survives the whole-target prune (ADR 0047 D4).
|
|
10
|
+
class Tree < Mode
|
|
11
|
+
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
12
|
+
target_rel = entry.publish_tree
|
|
13
|
+
target_dir = repo_abs(pctx, target_rel)
|
|
14
|
+
unless inside_repo?(pctx, target_dir)
|
|
15
|
+
raise Textus::PublishError.new(
|
|
16
|
+
"entry '#{entry.key}': publish_tree target '#{target_rel}' escapes repo root",
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
result = SubtreeMirror.new(entry, pctx).mirror(
|
|
21
|
+
base: store_base(pctx),
|
|
22
|
+
walk_root: store_base(pctx),
|
|
23
|
+
target_dir: target_dir,
|
|
24
|
+
key: entry.key,
|
|
25
|
+
envelope: nil,
|
|
26
|
+
prune_honors_ignore: true,
|
|
27
|
+
)
|
|
28
|
+
{ kind: :leaves, value: result[:written], pruned: result[:pruned] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate!
|
|
32
|
+
publish_tree = entry.publish_tree
|
|
33
|
+
raise UsageError.new("entry '#{entry.key}': publish_tree must be a string") unless publish_tree.is_a?(String)
|
|
34
|
+
|
|
35
|
+
unless entry.index_filename.nil?
|
|
36
|
+
raise UsageError.new(
|
|
37
|
+
"entry '#{entry.key}': index_filename and publish_tree are mutually exclusive — " \
|
|
38
|
+
"publish_tree mirrors a whole subtree by path and never enumerates an index.",
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
used_vars = publish_tree.scan(Template::VAR_RE).flatten
|
|
43
|
+
return if used_vars.empty?
|
|
44
|
+
|
|
45
|
+
raise UsageError.new(
|
|
46
|
+
"entry '#{entry.key}': publish_tree names a single directory and takes no template variable(s) " \
|
|
47
|
+
"#{used_vars.map { |v| "{#{v}}" }.join(", ")} — it mirrors the whole subtree to one target dir.",
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
# ADR 0049: the publish design is a three-key concept (ADR 0047 table)
|
|
5
|
+
# realized as one resolved sum type. Each directory entry resolves, once,
|
|
6
|
+
# to one Publish::* mode that owns its publish algorithm — no nil-cascade,
|
|
7
|
+
# no pairwise exclusivity guards, one shared subtree mirror.
|
|
8
|
+
#
|
|
9
|
+
# None — nothing to publish
|
|
10
|
+
# ToPaths — publish_to: 1 stored file -> N fixed repo paths
|
|
11
|
+
# EachFile — publish_each (file leaves): 1 leaf file -> 1 templated path
|
|
12
|
+
# EachDir — publish_each + index_filename: 1 leaf subtree -> 1 templated dir
|
|
13
|
+
# Tree — publish_tree: whole entry subtree -> 1 dir, no keys
|
|
14
|
+
module Publish
|
|
15
|
+
# Resolve an entry to its single publish mode. Raises one UsageError if
|
|
16
|
+
# more than one of {publish_to, publish_each, publish_tree} is set —
|
|
17
|
+
# exclusivity is structural here, not four scattered pairwise guards.
|
|
18
|
+
def self.resolve(entry)
|
|
19
|
+
set = []
|
|
20
|
+
set << "publish_to" unless Array(entry.publish_to).empty?
|
|
21
|
+
set << "publish_each" unless entry.publish_each.nil?
|
|
22
|
+
set << "publish_tree" unless entry.publish_tree.nil?
|
|
23
|
+
|
|
24
|
+
if set.length > 1
|
|
25
|
+
raise Textus::UsageError.new(
|
|
26
|
+
"entry '#{entry.key}': #{set.join(", ")} are mutually exclusive — an entry publishes exactly one way",
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
mode_for(entry, set.first)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.mode_for(entry, key)
|
|
34
|
+
case key
|
|
35
|
+
when "publish_to" then ToPaths.new(entry)
|
|
36
|
+
when "publish_tree" then Tree.new(entry)
|
|
37
|
+
when "publish_each" then entry.index_filename ? EachDir.new(entry) : EachFile.new(entry)
|
|
38
|
+
else None.new(entry)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
private_class_method :mode_for
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Validators
|
|
5
|
+
# ADR 0049: one publish validator. Exclusivity among the publish keys is
|
|
6
|
+
# enforced structurally by Publish.resolve (reached via #publish_mode),
|
|
7
|
+
# and each mode's shape rules run *because that mode resolved* — replacing
|
|
8
|
+
# the four scattered pairwise "not-both" guards of the old PublishEach +
|
|
9
|
+
# PublishTree validators. Misuse on a non-nested entry is still caught
|
|
10
|
+
# here from raw, since the typed attrs stub nil on Base.
|
|
11
|
+
module Publish
|
|
12
|
+
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
13
|
+
unless entry.nested?
|
|
14
|
+
%w[publish_each publish_tree].each do |key|
|
|
15
|
+
raise UsageError.new("entry '#{entry.key}': #{key} requires nested: true") if entry.raw[key]
|
|
16
|
+
end
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
entry.publish_mode.validate!
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -24,7 +24,7 @@ module Textus
|
|
|
24
24
|
KIND_REQUIRES_VERB = LANES
|
|
25
25
|
ENTRY_KEYS = %w[
|
|
26
26
|
key path zone kind schema owner nested format
|
|
27
|
-
compute template publish_to publish_each
|
|
27
|
+
compute template publish_to publish_each publish_tree
|
|
28
28
|
intake events inject_boot index_filename ignore tracked
|
|
29
29
|
].freeze
|
|
30
30
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
@@ -12,7 +12,7 @@ module Textus
|
|
|
12
12
|
module Publisher
|
|
13
13
|
def self.publish(source:, target:, store_root:)
|
|
14
14
|
FileUtils.mkdir_p(File.dirname(target))
|
|
15
|
-
|
|
15
|
+
guard_clobber(source, target, store_root)
|
|
16
16
|
File.delete(target) if File.symlink?(target)
|
|
17
17
|
FileUtils.cp(source, target)
|
|
18
18
|
Textus::Ports::SentinelStore.new.write!(target: target, source: source, store_root: store_root)
|
|
@@ -28,13 +28,25 @@ module Textus
|
|
|
28
28
|
FileUtils.rm_f(sentinel)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
# Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
|
|
32
|
+
# already equal the source (ADR 0050: a migration copies files into the
|
|
33
|
+
# store and publishes them back to where they already live, so the target
|
|
34
|
+
# is byte-identical — nothing is at risk). Adoption writes no sentinel
|
|
35
|
+
# here; the normal publish path below does, and the cp is a content no-op.
|
|
36
|
+
# An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
|
|
37
|
+
# still refused — that is the guard's real job.
|
|
38
|
+
def self.guard_clobber(source, target, store_root)
|
|
32
39
|
return unless File.exist?(target) || File.symlink?(target)
|
|
33
40
|
return if managed?(target, store_root)
|
|
41
|
+
return if adoptable?(source, target)
|
|
34
42
|
|
|
35
43
|
raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
|
|
36
44
|
end
|
|
37
45
|
|
|
46
|
+
def self.adoptable?(source, target)
|
|
47
|
+
!File.symlink?(target) && File.file?(target) && FileUtils.identical?(source, target)
|
|
48
|
+
end
|
|
49
|
+
|
|
38
50
|
def self.managed?(target, store_root)
|
|
39
51
|
File.exist?(Textus::Ports::SentinelStore.new.sentinel_path(target, store_root))
|
|
40
52
|
end
|