samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module MemoryBundle
|
|
9
|
+
# Normalizes a source (directory, zip, tar.gz, etc.) into a plain directory of .md files.
|
|
10
|
+
#
|
|
11
|
+
# Returns [normalized_path, owned] where:
|
|
12
|
+
# - dir sources: [dir_path, false] — caller owns the directory, do NOT delete.
|
|
13
|
+
# - archive sources: [temp_dir_path, true] — caller must call cleanup(dir).
|
|
14
|
+
class SourceNormalizer
|
|
15
|
+
class UnknownSourceError < StandardError; end
|
|
16
|
+
|
|
17
|
+
def self.normalize(source_path)
|
|
18
|
+
return normalize_git(source_path) if git_url?(source_path)
|
|
19
|
+
|
|
20
|
+
path = File.expand_path(source_path)
|
|
21
|
+
raise UnknownSourceError, "source does not exist: #{path}" unless File.exist?(path)
|
|
22
|
+
|
|
23
|
+
if File.directory?(path)
|
|
24
|
+
return [path, false]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Use full lowercase path for compound extensions like .tar.gz
|
|
28
|
+
lower = path.downcase
|
|
29
|
+
if lower.end_with?(".zip")
|
|
30
|
+
normalize_zip(path)
|
|
31
|
+
elsif lower.end_with?(".tar.gz") || lower.end_with?(".tgz") || lower.end_with?(".tar")
|
|
32
|
+
normalize_tar(path)
|
|
33
|
+
else
|
|
34
|
+
ext = File.extname(path).downcase
|
|
35
|
+
raise UnknownSourceError, "unsupported source format: #{path} (extension: #{ext})"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The bundles shipped with chi (lib/samagotchi/bundles/<name>).
|
|
40
|
+
SHIPPED_DIR = File.expand_path("../bundles", __dir__)
|
|
41
|
+
|
|
42
|
+
# A bare name (no path separator) that isn't a path here names a
|
|
43
|
+
# shipped bundle: `chi bundle install guardrails`. A directory or file
|
|
44
|
+
# of that name in the cwd wins.
|
|
45
|
+
# @return [String, nil] the shipped bundle's dir
|
|
46
|
+
def self.shipped_bundle_dir(source, cwd: Dir.pwd)
|
|
47
|
+
name = source.to_s.strip
|
|
48
|
+
return nil unless name.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
|
|
49
|
+
return nil if File.exist?(File.join(cwd, name))
|
|
50
|
+
|
|
51
|
+
dir = File.join(SHIPPED_DIR, name)
|
|
52
|
+
File.file?(File.join(dir, "manifest.yml")) ? dir : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.git_url?(str)
|
|
56
|
+
s = str.to_s.strip
|
|
57
|
+
return false if s.empty?
|
|
58
|
+
s.match?(%r{\A(?:https?://|git@|ssh://|git://|file://).+})
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.strip_git_ref(url)
|
|
62
|
+
if url.include?("#")
|
|
63
|
+
parts = url.split("#", 2)
|
|
64
|
+
[parts[0], parts[1]]
|
|
65
|
+
else
|
|
66
|
+
[url, nil]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.normalize_git(git_url)
|
|
71
|
+
url, ref = strip_git_ref(git_url.strip)
|
|
72
|
+
Dir.mktmpdir("samagotchi-git-") do |clone_parent|
|
|
73
|
+
clone_dir = File.join(clone_parent, "repo")
|
|
74
|
+
args = ["git", "clone", "--depth", "1"]
|
|
75
|
+
args += ["--branch", ref] if ref && !ref.empty?
|
|
76
|
+
args += [url, clone_dir]
|
|
77
|
+
result = system(*args)
|
|
78
|
+
raise UnknownSourceError, "git clone failed for #{git_url}" unless result && File.directory?(clone_dir)
|
|
79
|
+
# Capture HEAD commit before stripping .git (audit trail)
|
|
80
|
+
commit = nil
|
|
81
|
+
begin
|
|
82
|
+
commit = `git -C #{clone_dir.shellescape} rev-parse HEAD 2>/dev/null`.strip
|
|
83
|
+
commit = nil if commit.empty?
|
|
84
|
+
rescue StandardError
|
|
85
|
+
commit = nil
|
|
86
|
+
end
|
|
87
|
+
# Remove .git to avoid leaking
|
|
88
|
+
FileUtils.rm_rf(File.join(clone_dir, ".git"))
|
|
89
|
+
path, owned = clean_copy_of(clone_dir)
|
|
90
|
+
# Thread commit through via extra return value and class accessor for backward compat
|
|
91
|
+
@last_git_commit = commit
|
|
92
|
+
return [path, owned, commit]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
attr_accessor :last_git_commit
|
|
98
|
+
|
|
99
|
+
def cleanup(dir)
|
|
100
|
+
FileUtils.rm_rf(dir) if dir && File.directory?(dir)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def self.normalize_zip(zip_path)
|
|
107
|
+
Dir.mktmpdir("samagotchi-zip-") do |extract_dir|
|
|
108
|
+
result = system("unzip", "-o", zip_path, "-d", extract_dir)
|
|
109
|
+
raise UnknownSourceError, "unzip failed for #{zip_path}" unless result
|
|
110
|
+
clean_copy_of(extract_dir)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.normalize_tar(tar_path)
|
|
115
|
+
Dir.mktmpdir("samagotchi-tar-") do |extract_dir|
|
|
116
|
+
is_gz = tar_path.end_with?(".tar.gz", ".tgz")
|
|
117
|
+
cmd = is_gz ? ["tar", "-xzf"] : ["tar", "-xf"]
|
|
118
|
+
result = system(*cmd, tar_path, "-C", extract_dir)
|
|
119
|
+
raise UnknownSourceError, "tar extraction failed for #{tar_path}" unless result
|
|
120
|
+
clean_copy_of(extract_dir)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Copies the contents of `src_dir` into a new permanent directory, stripping
|
|
125
|
+
# one level of nesting if there's a single top-level entry.
|
|
126
|
+
# The returned path survives after `src_dir` is cleaned up.
|
|
127
|
+
def self.clean_copy_of(src_dir)
|
|
128
|
+
contents = Dir.entries(src_dir).reject { |e| e.start_with?(".") }
|
|
129
|
+
# Zip-slip check: reject entries with ".." path components.
|
|
130
|
+
contents.each do |entry|
|
|
131
|
+
if entry.include?("..")
|
|
132
|
+
raise UnknownSourceError, "archive contains unsafe path component: #{entry}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if contents.size == 1
|
|
137
|
+
entry_path = File.join(src_dir, contents.first)
|
|
138
|
+
if File.directory?(entry_path) && !Dir.empty?(entry_path)
|
|
139
|
+
dst = Dir.mktmpdir("samagotchi-bundle-")
|
|
140
|
+
FileUtils.cp_r("#{entry_path}/.", dst)
|
|
141
|
+
return [dst, true]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
dst = Dir.mktmpdir("samagotchi-bundle-")
|
|
146
|
+
contents.each do |entry|
|
|
147
|
+
FileUtils.cp_r(File.join(src_dir, entry), dst)
|
|
148
|
+
end
|
|
149
|
+
[dst, true]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "digest"
|
|
3
|
+
require_relative "provenance"
|
|
4
|
+
require_relative "manifest"
|
|
5
|
+
require_relative "../version"
|
|
6
|
+
require_relative "index_updater"
|
|
7
|
+
require_relative "../memory_paths"
|
|
8
|
+
require_relative "../bundle_needs"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
module MemoryBundle
|
|
12
|
+
module Status
|
|
13
|
+
def self.bundle_status(name, scope_override: nil)
|
|
14
|
+
provenance = Provenance.new(name: name)
|
|
15
|
+
data = provenance.read
|
|
16
|
+
return nil unless data
|
|
17
|
+
|
|
18
|
+
raw_scope = scope_override || data[:scope]&.to_s
|
|
19
|
+
scope = (raw_scope.nil? || raw_scope.strip.empty?) ? "system" : raw_scope
|
|
20
|
+
target_dir = resolve_target_dir(scope)
|
|
21
|
+
files = data[:files] || {}
|
|
22
|
+
details = {}
|
|
23
|
+
files.each do |file_key, meta|
|
|
24
|
+
file_key_str = file_key.to_s
|
|
25
|
+
target_path = File.join(target_dir, file_key_str)
|
|
26
|
+
base_path = provenance.base_path(file_key_str)
|
|
27
|
+
stored_checksum = meta[:checksum] || meta["checksum"]
|
|
28
|
+
current_checksum = File.exist?(target_path) ? Digest::SHA256.hexdigest(File.read(target_path)) : nil
|
|
29
|
+
base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
|
|
30
|
+
modified = base_checksum && current_checksum && base_checksum != current_checksum
|
|
31
|
+
missing = !File.exist?(target_path)
|
|
32
|
+
index_present = index_has_entry?(scope, file_key_str)
|
|
33
|
+
details[file_key_str] = {
|
|
34
|
+
stored_checksum: stored_checksum,
|
|
35
|
+
current_checksum: current_checksum,
|
|
36
|
+
base_checksum: base_checksum,
|
|
37
|
+
modified: modified,
|
|
38
|
+
missing: missing,
|
|
39
|
+
index_present: index_present,
|
|
40
|
+
target_path: target_path,
|
|
41
|
+
base_path: base_path
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
{ provenance: data, scope: scope, target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
|
|
45
|
+
needs: needs_status(data) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The stored needs, each with found: from this process's PATH (the
|
|
49
|
+
# shell's; a worker's PATH can differ, see docs/memory.md).
|
|
50
|
+
def self.needs_status(data, path: ENV["PATH"])
|
|
51
|
+
Manifest.parse_needs(data[:needs]).map do |need|
|
|
52
|
+
need.merge(found: BundleNeeds.found?(need[:command], path: path))
|
|
53
|
+
end
|
|
54
|
+
rescue Manifest::ValidationError
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# "needs gh (reads PRs) [ok]" / "needs gh [not found]: brew install gh"
|
|
59
|
+
def self.need_line(need)
|
|
60
|
+
why = need[:why] ? " (#{need[:why]})" : ""
|
|
61
|
+
state = need[:found] ? "[ok]" : "[not found]"
|
|
62
|
+
hint = !need[:found] && need[:hint] ? ": #{need[:hint]}" : ""
|
|
63
|
+
"needs #{need[:command]}#{why} #{state}#{hint}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The installed plugin: {file:, path:, state:, requires_chi:,
|
|
67
|
+
# requires_failure:}; state is "ok", "modified" (its sha256 differs
|
|
68
|
+
# from the installed one: it won't load) or "missing". nil without one.
|
|
69
|
+
def self.plugin_status(provenance, data)
|
|
70
|
+
path = provenance.plugin_path(data)
|
|
71
|
+
return nil unless path
|
|
72
|
+
|
|
73
|
+
recorded = data[:plugin][:sha256].to_s.delete_prefix("sha256:")
|
|
74
|
+
state = if !File.file?(path) then "missing"
|
|
75
|
+
elsif Digest::SHA256.hexdigest(File.binread(path)) == recorded then "ok"
|
|
76
|
+
else "modified"
|
|
77
|
+
end
|
|
78
|
+
{ file: File.basename(path), path: path, state: state, requires_chi: data[:requires_chi],
|
|
79
|
+
requires_failure: Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.resolve_target_dir(scope)
|
|
83
|
+
case scope
|
|
84
|
+
when "system"
|
|
85
|
+
Samagotchi::MemoryBundle::Installer.system_dir
|
|
86
|
+
when "project"
|
|
87
|
+
base = Samagotchi::MemoryBundle::Installer.project_dir_base
|
|
88
|
+
# If override set via Installer, use it; else compute hash
|
|
89
|
+
if Samagotchi::MemoryBundle::Installer.project_dir_base_override
|
|
90
|
+
base
|
|
91
|
+
else
|
|
92
|
+
File.join(base, MemoryPaths.project_key)
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
Samagotchi::MemoryBundle::Installer.system_dir
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.index_has_entry?(scope, file_key)
|
|
100
|
+
path = IndexUpdater.index_path_for(scope)
|
|
101
|
+
return false unless path && File.exist?(path)
|
|
102
|
+
content = File.read(path)
|
|
103
|
+
content.match?(IndexUpdater.managed_pattern(file_key))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "installer"
|
|
5
|
+
require_relative "provenance"
|
|
6
|
+
require_relative "manifest"
|
|
7
|
+
require_relative "../log"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module MemoryBundle
|
|
11
|
+
# Manages the built-in "samagotchi-system" bundle shipped inside the gem.
|
|
12
|
+
#
|
|
13
|
+
# The bundle lives at lib/samagotchi/bundles/system/ (manifest.yml + *.md)
|
|
14
|
+
# and is lazily installed into the user's system memories directory
|
|
15
|
+
# (~/.config/samagotchi/memories) on first Engine creation.
|
|
16
|
+
#
|
|
17
|
+
# A newer shipped version (manifest.yml) triggers a 3-way upgrade:
|
|
18
|
+
# fast-forward when not edited, keep when bundle unchanged, conflict keeps
|
|
19
|
+
# local edit with a warning. An older shipped version is left alone, so an
|
|
20
|
+
# old checkout never downgrades the installed bundle; it says so once.
|
|
21
|
+
module SystemBundle
|
|
22
|
+
BUNDLE_NAME = "samagotchi-system"
|
|
23
|
+
SCOPE = "system"
|
|
24
|
+
SKIP_ENV = "SAMAGOTCHI_SKIP_SYSTEM_BUNDLE"
|
|
25
|
+
GEM_BUNDLE_DIR = File.expand_path("../bundles/system", __dir__)
|
|
26
|
+
NOTED_FILE = "older_shipped_noted"
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Ensure the system bundle is installed and up-to-date.
|
|
31
|
+
# Idempotent and warn-only: never raises to callers.
|
|
32
|
+
# Safe for TUI and non-TUI (web/worker) paths — Engine calls this.
|
|
33
|
+
def ensure!
|
|
34
|
+
return false if skip?
|
|
35
|
+
return false unless File.directory?(GEM_BUNDLE_DIR)
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
gem_manifest = Manifest.read(dir: GEM_BUNDLE_DIR)
|
|
39
|
+
rescue Manifest::ValidationError => e
|
|
40
|
+
Log.error(:memory, "system_manifest_invalid", echo: "[samagotchi-system] invalid gem manifest: #{e.message}")
|
|
41
|
+
return false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
with_lock do
|
|
45
|
+
data = Provenance.new(name: BUNDLE_NAME).read
|
|
46
|
+
|
|
47
|
+
if data.nil?
|
|
48
|
+
install_fresh(gem_manifest)
|
|
49
|
+
elsif installed_newer?(data[:version], gem_manifest.version)
|
|
50
|
+
# Running an older checkout/gem: never downgrade the user's system memories
|
|
51
|
+
# (not even to restore a missing file). `chi self` shows installed vs shipped.
|
|
52
|
+
note_newer_installed(data[:version], gem_manifest.version)
|
|
53
|
+
false
|
|
54
|
+
elsif data[:version].to_s != gem_manifest.version.to_s
|
|
55
|
+
upgrade_existing(gem_manifest, data)
|
|
56
|
+
else
|
|
57
|
+
# Already at desired version — still verify files present (e.g. user deleted one)
|
|
58
|
+
verify_files_present(gem_manifest, data)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Log.error(:memory, "system_bundle_failed", echo: "[samagotchi-system] ensure failed: #{e.class}: #{e.message}", error: e.class.name)
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Parallel chi starts on one config dir take turns: the second one reads
|
|
67
|
+
# the provenance the first one wrote, instead of installing over it and
|
|
68
|
+
# warning "Skipped … already exists" for every file.
|
|
69
|
+
def with_lock
|
|
70
|
+
dir = Provenance.bundles_dir
|
|
71
|
+
FileUtils.mkdir_p(dir)
|
|
72
|
+
File.open(File.join(dir, "#{BUNDLE_NAME}.lock"), File::RDWR | File::CREAT, 0o644) do |f|
|
|
73
|
+
f.flock(File::LOCK_EX)
|
|
74
|
+
yield
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def skip?
|
|
79
|
+
val = ENV[SKIP_ENV].to_s.strip.downcase
|
|
80
|
+
val == "1" || val == "true"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Unparseable versions fall back to the old "any difference upgrades" behaviour.
|
|
84
|
+
def installed_newer?(installed, shipped)
|
|
85
|
+
installed, shipped = installed.to_s, shipped.to_s
|
|
86
|
+
return false unless Gem::Version.correct?(installed) && Gem::Version.correct?(shipped)
|
|
87
|
+
|
|
88
|
+
Gem::Version.new(installed) > Gem::Version.new(shipped)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# One line per installed/shipped pair, so an old worktree (and each of its
|
|
92
|
+
# workers) says it once instead of on every start. The pairs already noted
|
|
93
|
+
# sit in the bundle's provenance dir; ensure! holds the lock here.
|
|
94
|
+
def note_newer_installed(installed, shipped)
|
|
95
|
+
pair = "#{installed} #{shipped}"
|
|
96
|
+
path = File.join(Provenance.new(name: BUNDLE_NAME).bundle_dir, NOTED_FILE)
|
|
97
|
+
noted = File.exist?(path) ? File.readlines(path, chomp: true) : []
|
|
98
|
+
return if noted.include?(pair)
|
|
99
|
+
|
|
100
|
+
Log.warn(:memory, "system_bundle_newer", echo: "[samagotchi-system] installed system bundle #{installed} is newer than this chi's #{shipped}; left as is", installed: installed.to_s, shipped: shipped.to_s)
|
|
101
|
+
File.write(path, "#{pair}\n", mode: "a")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def install_fresh(gem_manifest)
|
|
105
|
+
installer = Installer.new(
|
|
106
|
+
source: GEM_BUNDLE_DIR,
|
|
107
|
+
name: BUNDLE_NAME,
|
|
108
|
+
scope: SCOPE,
|
|
109
|
+
force: false,
|
|
110
|
+
strict: true
|
|
111
|
+
)
|
|
112
|
+
_nd, _manifest = installer.run
|
|
113
|
+
unless installer.warnings.empty?
|
|
114
|
+
installer.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") }
|
|
115
|
+
end
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def upgrade_existing(gem_manifest, _existing_data)
|
|
120
|
+
installer = Installer.new(
|
|
121
|
+
source: GEM_BUNDLE_DIR,
|
|
122
|
+
name: BUNDLE_NAME,
|
|
123
|
+
scope: SCOPE,
|
|
124
|
+
force: false,
|
|
125
|
+
strict: true,
|
|
126
|
+
upgrade: true
|
|
127
|
+
)
|
|
128
|
+
_nd, _manifest = installer.run
|
|
129
|
+
# Installer already handled fast_forward/keep/noop. Conflicts are kept with warning.
|
|
130
|
+
if installer.conflicts.any?
|
|
131
|
+
installer.conflicts.each do |file_key, _info|
|
|
132
|
+
Log.info(:memory, "system_local_edit_kept", echo: "[samagotchi-system] kept local edit in #{file_key} (bundle v#{gem_manifest.version} has update — run: chi bundle status #{BUNDLE_NAME} / chi bundle diff #{BUNDLE_NAME} #{file_key})", file: file_key.to_s)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
installer.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") } unless installer.warnings.empty?
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def verify_files_present(gem_manifest, data)
|
|
140
|
+
# If any file from the bundle is missing on disk but provenance says it should exist,
|
|
141
|
+
# reinstall that file via Installer (which will classify as :install). This handles
|
|
142
|
+
# accidental user deletion without forcing a full reinstall.
|
|
143
|
+
target_dir = Installer.system_dir
|
|
144
|
+
missing = gem_manifest.files.keys.any? { |k| !File.exist?(File.join(target_dir, k)) }
|
|
145
|
+
return false unless missing
|
|
146
|
+
|
|
147
|
+
installer = Installer.new(
|
|
148
|
+
source: GEM_BUNDLE_DIR,
|
|
149
|
+
name: BUNDLE_NAME,
|
|
150
|
+
scope: SCOPE,
|
|
151
|
+
force: false,
|
|
152
|
+
strict: true,
|
|
153
|
+
upgrade: true
|
|
154
|
+
)
|
|
155
|
+
_nd, _manifest = installer.run
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../memory_paths"
|
|
5
|
+
require_relative "provenance"
|
|
6
|
+
require_relative "index_updater"
|
|
7
|
+
require_relative "merger"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module MemoryBundle
|
|
11
|
+
class Uninstaller
|
|
12
|
+
class UninstallError < StandardError; end
|
|
13
|
+
|
|
14
|
+
attr_reader :warnings, :removed_files
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :system_dir_override, :project_dir_base_override
|
|
18
|
+
def system_dir
|
|
19
|
+
system_dir_override || MemoryPaths.system_dir
|
|
20
|
+
end
|
|
21
|
+
def project_dir_base
|
|
22
|
+
project_dir_base_override || MemoryPaths.projects_dir
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(name:, scope: nil, force: false)
|
|
27
|
+
@name = name
|
|
28
|
+
@scope = scope&.to_s&.strip&.downcase
|
|
29
|
+
@force = force
|
|
30
|
+
@warnings = []
|
|
31
|
+
@removed_files = []
|
|
32
|
+
IndexUpdater.system_dir_override = self.class.system_dir_override
|
|
33
|
+
IndexUpdater.project_dir_base_override = self.class.project_dir_base_override
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run
|
|
37
|
+
provenance = Provenance.new(name: @name)
|
|
38
|
+
data = provenance.read
|
|
39
|
+
raise UninstallError, "Bundle '#{@name}' is not installed" unless data
|
|
40
|
+
|
|
41
|
+
# Determine scope from flag or provenance
|
|
42
|
+
cli_scope = @scope.to_s.strip.empty? ? nil : @scope
|
|
43
|
+
raw = cli_scope || data[:scope]&.to_s
|
|
44
|
+
target_scope = (raw.nil? || raw.strip.empty?) ? "system" : raw
|
|
45
|
+
target_dir = resolve_target_dir(target_scope)
|
|
46
|
+
|
|
47
|
+
files = data[:files] || {}
|
|
48
|
+
hooks = data[:hooks] || {}
|
|
49
|
+
if files.empty? && hooks.empty?
|
|
50
|
+
# No file list, just remove provenance
|
|
51
|
+
FileUtils.rm_rf(provenance.bundle_dir)
|
|
52
|
+
return true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
blocked = []
|
|
56
|
+
files.each do |file_key, meta|
|
|
57
|
+
file_key_str = file_key.to_s
|
|
58
|
+
target_path = File.join(target_dir, file_key_str)
|
|
59
|
+
base_path = provenance.base_path(file_key_str)
|
|
60
|
+
next unless File.exist?(target_path)
|
|
61
|
+
if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
|
|
62
|
+
blocked << file_key_str
|
|
63
|
+
@warnings << "Skipped #{file_key_str}: local edits detected (use --force to remove)"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
raise UninstallError, "Uninstall blocked: #{blocked.join(', ')} has local edits (use --force)" if blocked.any?
|
|
68
|
+
|
|
69
|
+
files.each do |file_key, _meta|
|
|
70
|
+
file_key_str = file_key.to_s
|
|
71
|
+
target_path = File.join(target_dir, file_key_str)
|
|
72
|
+
if File.exist?(target_path)
|
|
73
|
+
FileUtils.rm_f(target_path)
|
|
74
|
+
@removed_files << file_key_str
|
|
75
|
+
end
|
|
76
|
+
# Remove index line
|
|
77
|
+
begin
|
|
78
|
+
IndexUpdater.remove_index(target_scope, file_key_str.delete_suffix(".md"))
|
|
79
|
+
IndexUpdater.remove_index(target_scope, file_key_str) # legacy "name.md" line
|
|
80
|
+
rescue => _e
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ── Hooks removal (bundle-owned) ──────────────────────────
|
|
85
|
+
# Hooks are stored under <bundle_dir>/hooks/ and are not part of the index.
|
|
86
|
+
# Explicit removal for intent; the final rm_rf sweeps it anyway.
|
|
87
|
+
if hooks.is_a?(Hash) && !hooks.empty?
|
|
88
|
+
hooks_dir = provenance.hooks_dir
|
|
89
|
+
hooks.each do |hook_key, _meta|
|
|
90
|
+
hook_key_str = hook_key.to_s
|
|
91
|
+
hook_path = File.join(hooks_dir, hook_key_str)
|
|
92
|
+
if File.exist?(hook_path)
|
|
93
|
+
FileUtils.rm_f(hook_path)
|
|
94
|
+
@removed_files << "hooks/#{hook_key_str}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
# Remove hooks dir if empty, else rm_rf will clean
|
|
98
|
+
FileUtils.rm_rf(hooks_dir) if Dir.exist?(hooks_dir)
|
|
99
|
+
else
|
|
100
|
+
# Ensure stale hooks dir removed even if provenance has no hooks map but dir exists
|
|
101
|
+
FileUtils.rm_rf(provenance.hooks_dir) if Dir.exist?(provenance.hooks_dir)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
plugin_path = provenance.plugin_path(data)
|
|
105
|
+
@removed_files << "plugin/#{File.basename(plugin_path)}" if plugin_path && File.exist?(plugin_path)
|
|
106
|
+
FileUtils.rm_rf(provenance.bundle_dir)
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def resolve_target_dir(scope)
|
|
113
|
+
case scope
|
|
114
|
+
when "system", ""
|
|
115
|
+
self.class.system_dir
|
|
116
|
+
when "project"
|
|
117
|
+
if self.class.project_dir_base_override
|
|
118
|
+
self.class.project_dir_base
|
|
119
|
+
else
|
|
120
|
+
File.join(self.class.project_dir_base, MemoryPaths.project_key)
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
raise UninstallError, "invalid scope: #{scope}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "memory_bundle/manifest"
|
|
3
|
+
require_relative "memory_bundle/source"
|
|
4
|
+
require_relative "memory_bundle/provenance"
|
|
5
|
+
require_relative "memory_bundle/placeholder"
|
|
6
|
+
require_relative "memory_bundle/index_updater"
|
|
7
|
+
require_relative "memory_bundle/merger"
|
|
8
|
+
require_relative "memory_bundle/installer"
|
|
9
|
+
require_relative "memory_bundle/uninstaller"
|
|
10
|
+
require_relative "memory_bundle/status"
|
|
11
|
+
require_relative "memory_bundle/system_bundle"
|
|
12
|
+
require_relative "memory_bundle/builder"
|
|
13
|
+
require_relative "memory_bundle/listing"
|
|
14
|
+
module Samagotchi
|
|
15
|
+
# Namespace for shareable memory bundle functionality.
|
|
16
|
+
module MemoryBundle; end
|
|
17
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# Where memories live: <config dir>/memories, i.e. $XDG_CONFIG_HOME/samagotchi/memories
|
|
8
|
+
# or ~/.config/samagotchi/memories. Resolved on every call, like
|
|
9
|
+
# ConfigFile.global_path, so an XDG_CONFIG_HOME set after load (specs, smoke runs,
|
|
10
|
+
# a fixture chi) moves memories together with config.yml.
|
|
11
|
+
#
|
|
12
|
+
# Project memories are keyed by the project root (see project_root), so every
|
|
13
|
+
# worktree and subdirectory of one git repository shares one folder.
|
|
14
|
+
module MemoryPaths
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def system_dir(env: ENV)
|
|
18
|
+
File.join(ConfigFile.config_dir(env: env), "memories")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def projects_dir(env: ENV)
|
|
22
|
+
File.join(system_dir(env: env), "projects")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# One folder per project root: basename + 8 hex chars of MD5(full root path).
|
|
26
|
+
def project_key(cwd = Dir.pwd)
|
|
27
|
+
root = project_root(cwd)
|
|
28
|
+
"#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The repository's common git directory identifies the project; every linked
|
|
32
|
+
# worktree shares it. Returns the checkout holding it when it is named .git,
|
|
33
|
+
# otherwise the git dir itself (a bare repo, a proj/.bare layout,
|
|
34
|
+
# --separate-git-dir, a submodule's .git/modules/<name>), so for those layouts
|
|
35
|
+
# the "project root" is a git dir, not a checkout. Outside any repository the
|
|
36
|
+
# root is cwd. No git subprocess: at most two small files are read.
|
|
37
|
+
def project_root(cwd = Dir.pwd)
|
|
38
|
+
dot_git = find_dot_git(cwd)
|
|
39
|
+
return cwd unless dot_git
|
|
40
|
+
|
|
41
|
+
holder = File.dirname(dot_git)
|
|
42
|
+
gitdir = File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
|
|
43
|
+
return holder unless gitdir
|
|
44
|
+
|
|
45
|
+
common = common_dir(gitdir)
|
|
46
|
+
common = begin
|
|
47
|
+
File.realpath(common)
|
|
48
|
+
rescue SystemCallError
|
|
49
|
+
common
|
|
50
|
+
end
|
|
51
|
+
File.basename(common) == ".git" ? File.dirname(common) : common
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Is +cwd+ inside a git repository (a .git somewhere above it)?
|
|
55
|
+
def in_repo?(cwd = Dir.pwd)
|
|
56
|
+
!find_dot_git(cwd).nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find_dot_git(start)
|
|
60
|
+
dir = File.expand_path(start)
|
|
61
|
+
loop do
|
|
62
|
+
candidate = File.join(dir, ".git")
|
|
63
|
+
return candidate if File.exist?(candidate)
|
|
64
|
+
|
|
65
|
+
parent = File.dirname(dir)
|
|
66
|
+
return nil if parent == dir
|
|
67
|
+
|
|
68
|
+
dir = parent
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A ".git" file holds "gitdir: <path>", relative to the file's directory.
|
|
73
|
+
def gitdir_from_file(path)
|
|
74
|
+
line = File.read(path, 4096).to_s[/\Agitdir:\s*(.+)$/, 1]&.strip
|
|
75
|
+
return nil if line.nil? || line.empty?
|
|
76
|
+
|
|
77
|
+
File.expand_path(line, File.dirname(path))
|
|
78
|
+
rescue SystemCallError, ArgumentError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def common_dir(gitdir)
|
|
83
|
+
file = File.join(gitdir, "commondir")
|
|
84
|
+
return gitdir unless File.file?(file)
|
|
85
|
+
|
|
86
|
+
rel = File.read(file).strip
|
|
87
|
+
rel.empty? ? gitdir : File.expand_path(rel, gitdir)
|
|
88
|
+
rescue SystemCallError
|
|
89
|
+
gitdir
|
|
90
|
+
end
|
|
91
|
+
private_class_method :find_dot_git, :gitdir_from_file, :common_dir
|
|
92
|
+
|
|
93
|
+
def project_dir(env: ENV, cwd: Dir.pwd)
|
|
94
|
+
File.join(projects_dir(env: env), project_key(cwd))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def bundles_dir(env: ENV)
|
|
98
|
+
File.join(system_dir(env: env), ".bundles")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|