woods 1.6.1 → 2.0.0.beta2

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.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'pathname'
5
+ require 'digest'
6
+ require 'securerandom'
7
+
8
+ require_relative 'atomic_file'
9
+
10
+ module Woods
11
+ # A monotonic marker for "which version of the index is on disk".
12
+ #
13
+ # The index is a directory of files, so a reader has no cheap way to ask
14
+ # "has this changed since I cached it?" — it would have to stat everything.
15
+ # This is that cheap way: one small file, bumped as the *last* step of any
16
+ # write, holding a counter that only ever increases plus a token that
17
+ # changes whenever the counter does.
18
+ #
19
+ # Two properties matter, and both come from #164's central rule — never
20
+ # advance a cursor over work that didn't land:
21
+ #
22
+ # * **Bumped last.** Callers write the index, then bump. A reader that sees
23
+ # generation N therefore knows the files for N are already there.
24
+ # * **Not bumped on failure.** A run that raised, or that changed nothing,
25
+ # leaves the generation alone. Staleness is then honest: the reader keeps
26
+ # serving N and knows it.
27
+ #
28
+ # The write itself is atomic ({AtomicFile}), so a reader never sees a torn
29
+ # generation file. It can also carry a **payload pointer** — the name of an
30
+ # immutable per-generation directory the payload was published into. When
31
+ # present, the single atomic write of this file is the one commit point for
32
+ # the whole payload: a reader resolves every artifact of a read through the
33
+ # directory this generation names, so it sees one generation whole rather
34
+ # than a mix of a manifest from N+1 next to a unit from N. The pointer is
35
+ # optional — an index written flat (payload files directly under the output
36
+ # directory) carries none, and readers fall back to the output directory.
37
+ #
38
+ # @example Publishing after a successful write
39
+ # Woods::Generation.new(output_dir: "tmp/woods").bump!(reason: "incremental")
40
+ # # => #<Generation::Marker number=42 token="9f2c…" reason="incremental">
41
+ #
42
+ class Generation
43
+ FILENAME = 'generation.json'
44
+
45
+ # An immutable snapshot of the generation file.
46
+ #
47
+ # @!attribute number
48
+ # @return [Integer] monotonically increasing, starting at 1
49
+ # @!attribute token
50
+ # @return [String] opaque; changes on every bump
51
+ # @!attribute updated_at
52
+ # @return [String, nil] ISO8601 stamp of the bump
53
+ # @!attribute reason
54
+ # @return [String, nil] what produced this generation
55
+ # @!attribute payload
56
+ # @return [String, nil] name of the immutable directory this generation's
57
+ # payload lives in, relative to the output directory. nil for an index
58
+ # written flat (payload files directly under the output directory).
59
+ Marker = Struct.new(:number, :token, :updated_at, :reason, :payload, keyword_init: true) do
60
+ # @return [Hash] string-keyed, ready to serialize. The +payload+ key is
61
+ # emitted only when set, so a flat index's generation file is byte-for-
62
+ # byte what it always was.
63
+ def to_h
64
+ base = { 'number' => number, 'token' => token, 'updated_at' => updated_at, 'reason' => reason }
65
+ base['payload'] = payload if payload
66
+ base
67
+ end
68
+ end
69
+
70
+ # Marker returned when no generation file exists yet — an index written
71
+ # before generations existed, or a directory nothing has published to.
72
+ UNPUBLISHED = Marker.new(number: 0, token: nil, updated_at: nil, reason: nil).freeze
73
+
74
+ # @param output_dir [String, Pathname] index directory
75
+ # @param clock [#call] returns the ISO8601 stamp for a bump; injectable
76
+ # so specs don't depend on wall-clock time
77
+ def initialize(output_dir:, clock: -> { Time.now.utc.iso8601 })
78
+ @path = File.join(output_dir.to_s, FILENAME)
79
+ @clock = clock
80
+ end
81
+
82
+ # @return [String] absolute path to the generation file
83
+ attr_reader :path
84
+
85
+ # The generation currently published.
86
+ #
87
+ # A missing or unreadable file reads as {UNPUBLISHED} rather than raising:
88
+ # a reader asking "what generation is this?" of a directory that has none
89
+ # wants an answer, not an exception.
90
+ #
91
+ # @return [Marker]
92
+ def current
93
+ return UNPUBLISHED unless File.exist?(@path)
94
+
95
+ data = JSON.parse(AtomicFile.read(@path))
96
+ Marker.new(
97
+ number: data['number'].to_i,
98
+ token: data['token'],
99
+ updated_at: data['updated_at'],
100
+ reason: data['reason'],
101
+ payload: data['payload']
102
+ )
103
+ rescue JSON::ParserError, SystemCallError
104
+ UNPUBLISHED
105
+ end
106
+
107
+ # Advance to the next generation.
108
+ #
109
+ # Call this *after* the index files are written, never before, and never
110
+ # after a run that failed or wrote nothing.
111
+ #
112
+ # @param reason [String, nil] what produced this generation, for humans
113
+ # reading `woods_status`
114
+ # @param payload [String, nil] name of the immutable directory this
115
+ # generation's payload was published into, relative to the output
116
+ # directory. Omit for a flat index; when given, this bump becomes the
117
+ # single commit point for that whole payload (see the class docs).
118
+ # @return [Marker] the newly published generation
119
+ def bump!(reason: nil, payload: nil)
120
+ marker = Marker.new(
121
+ number: current.number + 1,
122
+ token: SecureRandom.hex(8),
123
+ updated_at: @clock.call,
124
+ reason: reason,
125
+ payload: payload
126
+ )
127
+ AtomicFile.write(@path, JSON.generate(marker.to_h))
128
+ marker
129
+ end
130
+
131
+ # The directory a generation's payload artifacts live in.
132
+ #
133
+ # Returns the index root when there is no pointer (an index written flat,
134
+ # which is every index written before payloads existed), when the named
135
+ # directory is gone (a stale pointer — a payload retention pruned), or
136
+ # when the name would escape the index root. A torn or tampered pointer
137
+ # must degrade to the flat layout, never read outside the index; the name
138
+ # is written by Woods and is relative by contract.
139
+ #
140
+ # Every reader of a payload artifact resolves through this, so the pointer
141
+ # is followed identically wherever it is read.
142
+ #
143
+ # @param marker [Marker] the generation to resolve; defaults to the
144
+ # published one. Pass an already-loaded marker to avoid re-reading, and
145
+ # to resolve a generation a caller has pinned rather than the current one.
146
+ # @return [Pathname]
147
+ def payload_dir(marker = current)
148
+ name = marker.payload
149
+ return root if name.nil? || name.empty?
150
+
151
+ candidate = root.join(name)
152
+ return root unless candidate.directory?
153
+ return root unless within_root?(candidate)
154
+
155
+ candidate
156
+ end
157
+
158
+ # @return [Pathname] the index directory this generation lives in
159
+ def root
160
+ @root ||= Pathname.new(File.dirname(@path))
161
+ end
162
+
163
+ private
164
+
165
+ # Does +candidate+ resolve inside the index root?
166
+ #
167
+ # Compared through +realpath+, mirroring
168
+ # {Woods::IndexArtifact#validate_dump_dir!} (B-134). +expand_path+ is
169
+ # textual, so a symlink planted *inside* +payloads/+ and pointing outside
170
+ # the index passed the check while +Pathname#directory?+ happily followed
171
+ # it — aiming every payload reader at an arbitrary directory (CORE-6). An
172
+ # index reached through a symlinked root still resolves, because both
173
+ # sides go through realpath.
174
+ #
175
+ # @param candidate [Pathname] an existing directory
176
+ # @return [Boolean] false when it cannot be resolved at all
177
+ def within_root?(candidate)
178
+ candidate_real = candidate.realpath.to_s
179
+ root_real = root.realpath.to_s
180
+ candidate_real.start_with?("#{root_real}#{File::SEPARATOR}")
181
+ rescue SystemCallError
182
+ false
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ # The one place a Woods git command line is built.
5
+ #
6
+ # Every git call Woods makes is rooted at the extracted application rather
7
+ # than at the process working directory, so extraction launched from another
8
+ # checkout never reports that checkout's history.
9
+ #
10
+ # `WOODS_GIT_DIR` wins when set. It is the escape hatch for a container over
11
+ # a linked worktree: `.git` there is a file naming an absolute host path that
12
+ # may not be mounted, and git's own `GIT_DIR` is not enough, because a
13
+ # worktree's private git directory reaches the shared object store through a
14
+ # relative `commondir` pointer that resolves outside the mount, which
15
+ # `GIT_COMMON_DIR` does not override (B-181, B-186).
16
+ #
17
+ # Three call sites use this, and the documentation promises all three:
18
+ # per-unit enrichment (`Extractor`), manifest provenance (`GitProvenance`),
19
+ # and the `woods:incremental` diff range (`lib/tasks/woods.rake`).
20
+ module GitCommand
21
+ # Environment variable naming the canonical git directory.
22
+ OVERRIDE_KEY = 'WOODS_GIT_DIR'
23
+
24
+ module_function
25
+
26
+ # @param root [String, Pathname] repository root the command runs against
27
+ # @param args [Array<String>] git arguments, global options included
28
+ # @param env [Hash] environment source (overridable in specs)
29
+ # @return [Array<String>] full argv for `Open3`
30
+ def argv(root, *args, env: ENV)
31
+ root = root.to_s
32
+ override = env[OVERRIDE_KEY]
33
+ return ['git', '-C', root, *args] if override.nil? || override.empty?
34
+
35
+ ['git', '--git-dir', override, '--work-tree', root, '-C', root, *args]
36
+ end
37
+ end
38
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require_relative 'git_command'
4
5
 
5
6
  module Woods
6
7
  # Resolves git provenance (branch + commit SHA) for the extracted codebase,
@@ -78,12 +79,25 @@ module Woods
78
79
  #
79
80
  # @return [String] stripped output, or empty string on any failure
80
81
  def rev_parse(*args)
81
- out, status = Open3.capture2('git', '-C', @root, 'rev-parse', *args)
82
+ out, _err, status = Open3.capture3(*git_argv('rev-parse', *args))
82
83
  status.success? ? out.strip : ''
83
84
  rescue StandardError
84
85
  ''
85
86
  end
86
87
 
88
+ # +WOODS_GIT_DIR+ names the canonical git directory outright and wins when
89
+ # set. It is the escape hatch for a container that can mount that
90
+ # directory but not the host path a linked worktree's +.git+ file points
91
+ # at; git's own +GIT_DIR+ is not enough there, because a worktree's
92
+ # private git directory reaches the shared one through a relative
93
+ # +commondir+ pointer that resolves outside the mount.
94
+ #
95
+ # @param args [Array<String>] git arguments
96
+ # @return [Array<String>] full argv
97
+ def git_argv(*args)
98
+ GitCommand.argv(@root, *args, env: @env)
99
+ end
100
+
87
101
  # Decide what to emit when git resolution produced nothing. A baked +GIT_*+
88
102
  # env var is honored only when this checkout is not a git working tree (no
89
103
  # +.git+ at the root) or git is unavailable. When git is present AND a +.git+
@@ -103,7 +117,7 @@ module Woods
103
117
  return @git_available if defined?(@git_available)
104
118
 
105
119
  @git_available = begin
106
- _out, status = Open3.capture2('git', '--version')
120
+ _out, _err, status = Open3.capture3('git', '--version')
107
121
  status.success?
108
122
  rescue StandardError
109
123
  false