ace-git-worktree 0.26.1 → 0.27.1
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/.ace-defaults/nav/protocols/skill-sources/ace-git-worktree.yml +0 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +0 -0
- data/CHANGELOG.md +62 -0
- data/LICENSE +0 -0
- data/README.md +0 -0
- data/Rakefile +0 -0
- data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
- data/docs/demo/ace-git-worktree-getting-started.tape.yml +0 -0
- data/docs/demo/fixtures/README.md +0 -0
- data/docs/demo/fixtures/sample.txt +0 -0
- data/docs/getting-started.md +7 -4
- data/docs/handbook.md +0 -0
- data/docs/usage.md +153 -24
- data/handbook/agents/worktree.ag.md +10 -6
- data/handbook/skills/as-git-worktree/SKILL.md +0 -0
- data/handbook/skills/as-git-worktree-cleanup/SKILL.md +0 -0
- data/handbook/skills/as-git-worktree-create/SKILL.md +0 -0
- data/handbook/skills/as-git-worktree-manage/SKILL.md +0 -0
- data/handbook/workflow-instructions/git/worktree-create.wf.md +3 -3
- data/handbook/workflow-instructions/git/worktree-manage.wf.md +6 -6
- data/handbook/workflow-instructions/git/worktree.wf.md +2 -2
- data/lib/ace/git/worktree/atoms/duration_parser.rb +81 -0
- data/lib/ace/git/worktree/atoms/git_command.rb +0 -0
- data/lib/ace/git/worktree/atoms/path_expander.rb +0 -0
- data/lib/ace/git/worktree/atoms/relative_age_formatter.rb +59 -0
- data/lib/ace/git/worktree/atoms/slug_generator.rb +0 -0
- data/lib/ace/git/worktree/atoms/task_id_extractor.rb +20 -1
- data/lib/ace/git/worktree/atoms/text_truncator.rb +67 -0
- data/lib/ace/git/worktree/cli/commands/bootstrap.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/cleanup.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/config.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/create.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/list.rb +29 -8
- data/lib/ace/git/worktree/cli/commands/prune.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/remove.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/switch.rb +0 -0
- data/lib/ace/git/worktree/cli.rb +1 -1
- data/lib/ace/git/worktree/commands/bootstrap_command.rb +0 -0
- data/lib/ace/git/worktree/commands/list_command.rb +90 -94
- data/lib/ace/git/worktree/commands/prune_command.rb +0 -0
- data/lib/ace/git/worktree/commands/remove_command.rb +0 -0
- data/lib/ace/git/worktree/commands/switch_command.rb +0 -0
- data/lib/ace/git/worktree/configuration.rb +0 -0
- data/lib/ace/git/worktree/models/task_index.rb +186 -0
- data/lib/ace/git/worktree/models/worktree_info.rb +52 -3
- data/lib/ace/git/worktree/models/worktree_metadata.rb +0 -0
- data/lib/ace/git/worktree/molecules/bootstrap_executor.rb +0 -0
- data/lib/ace/git/worktree/molecules/cleanup_applier.rb +0 -0
- data/lib/ace/git/worktree/molecules/config_loader.rb +0 -0
- data/lib/ace/git/worktree/molecules/current_task_linker.rb +0 -0
- data/lib/ace/git/worktree/molecules/hook_executor.rb +0 -0
- data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +0 -0
- data/lib/ace/git/worktree/molecules/pr_status_resolver.rb +172 -0
- data/lib/ace/git/worktree/molecules/recent_branch_counter.rb +70 -0
- data/lib/ace/git/worktree/molecules/task_association_resolver.rb +121 -0
- data/lib/ace/git/worktree/molecules/task_fetcher.rb +0 -0
- data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -0
- data/lib/ace/git/worktree/molecules/task_pusher.rb +0 -0
- data/lib/ace/git/worktree/molecules/task_status_updater.rb +0 -0
- data/lib/ace/git/worktree/molecules/toolchain_truster.rb +0 -0
- data/lib/ace/git/worktree/molecules/worktree_footer_formatter.rb +43 -0
- data/lib/ace/git/worktree/molecules/worktree_lister.rb +22 -93
- data/lib/ace/git/worktree/molecules/worktree_probe.rb +196 -0
- data/lib/ace/git/worktree/molecules/worktree_remover.rb +0 -0
- data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +241 -0
- data/lib/ace/git/worktree/organisms/worktree_list_report.rb +315 -0
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +22 -20
- data/lib/ace/git/worktree/organisms/worktree_probe_runner.rb +320 -0
- data/lib/ace/git/worktree/version.rb +1 -1
- data/lib/ace/git/worktree.rb +14 -0
- metadata +15 -2
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "date"
|
|
5
|
+
require "open3"
|
|
6
|
+
require_relative "../atoms/task_id_extractor"
|
|
7
|
+
require_relative "../models/task_index"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Git
|
|
11
|
+
module Worktree
|
|
12
|
+
module Molecules
|
|
13
|
+
# Builds the `.ace-tasks/` task index once per listing run.
|
|
14
|
+
#
|
|
15
|
+
# Scans every task spec (`*.s.md`) under the project's task tree,
|
|
16
|
+
# including special folders such as `_archive/` and `_maybe/`, and reads
|
|
17
|
+
# only the frontmatter (``id`` plus recorded ``worktree`` metadata).
|
|
18
|
+
# Current-tree entries win over archived entries for the same short id.
|
|
19
|
+
class TaskIndexBuilder
|
|
20
|
+
# File glob for task specs under the task root.
|
|
21
|
+
SPEC_GLOB = "**/*.s.md"
|
|
22
|
+
|
|
23
|
+
# @param project_root [String, nil] project root (defaults to the git
|
|
24
|
+
# common-dir parent of the current repository)
|
|
25
|
+
def initialize(project_root: nil)
|
|
26
|
+
@project_root = project_root
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Models::TaskIndex]
|
|
30
|
+
def build
|
|
31
|
+
root = resolved_project_root
|
|
32
|
+
tasks_root = root ? File.join(root, ".ace-tasks") : nil
|
|
33
|
+
unless tasks_root && Dir.exist?(tasks_root)
|
|
34
|
+
return Models::TaskIndex.new(available: false, project_root: root)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
entries = Dir.glob(File.join(tasks_root, SPEC_GLOB)).filter_map do |spec_path|
|
|
38
|
+
entry_for(spec_path, root)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Models::TaskIndex.new(entries: deduplicate(entries), available: true, project_root: root)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Resolve the project root: the primary working tree that holds
|
|
47
|
+
# `.ace-tasks/`. Uses the git common dir so a linked worktree still
|
|
48
|
+
# reads the project's task tree.
|
|
49
|
+
def resolved_project_root
|
|
50
|
+
return File.expand_path(@project_root) if @project_root
|
|
51
|
+
|
|
52
|
+
out, status = Open3.capture2("git", "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
53
|
+
return Dir.pwd unless status.success?
|
|
54
|
+
|
|
55
|
+
common_dir = out.to_s.strip
|
|
56
|
+
return Dir.pwd if common_dir.empty?
|
|
57
|
+
|
|
58
|
+
File.dirname(common_dir)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
Dir.pwd
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Models::TaskIndex::Entry, nil]
|
|
64
|
+
def entry_for(spec_path, root)
|
|
65
|
+
frontmatter = read_frontmatter(spec_path)
|
|
66
|
+
return nil if frontmatter.nil?
|
|
67
|
+
|
|
68
|
+
full_id = frontmatter["id"].to_s.strip
|
|
69
|
+
short_id = Atoms::TaskIDExtractor.short_id(full_id) ||
|
|
70
|
+
Atoms::TaskIDExtractor.normalize(full_id)
|
|
71
|
+
return nil if short_id.nil? || short_id.empty? || full_id.empty?
|
|
72
|
+
|
|
73
|
+
worktree = frontmatter["worktree"].is_a?(Hash) ? frontmatter["worktree"] : {}
|
|
74
|
+
Models::TaskIndex::Entry.new(
|
|
75
|
+
full_id: full_id,
|
|
76
|
+
short_id: short_id,
|
|
77
|
+
directory: File.dirname(spec_path),
|
|
78
|
+
spec_path: spec_path,
|
|
79
|
+
worktree_path: expand_worktree_path(worktree["path"], root),
|
|
80
|
+
worktree_branch: blank_to_nil(worktree["branch"])
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Hash, nil] parsed YAML frontmatter
|
|
85
|
+
def read_frontmatter(spec_path)
|
|
86
|
+
content = File.read(spec_path)
|
|
87
|
+
match = content.match(/\A---\s*\n(.*?)\n---\s*(?:\n|\z)/m)
|
|
88
|
+
return nil unless match
|
|
89
|
+
|
|
90
|
+
YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: true) || {}
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def expand_worktree_path(path, root)
|
|
96
|
+
return nil if path.to_s.strip.empty?
|
|
97
|
+
|
|
98
|
+
File.expand_path(path.to_s.strip, root)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def blank_to_nil(value)
|
|
102
|
+
value.to_s.strip.empty? ? nil : value.to_s.strip
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Keep one entry per short id: the current tree wins over special
|
|
106
|
+
# folders (first path segment starting with "_").
|
|
107
|
+
def deduplicate(entries)
|
|
108
|
+
entries.group_by(&:short_id).map do |_short_id, candidates|
|
|
109
|
+
candidates.min_by { |entry| [archived?(entry) ? 1 : 0, entry.directory] }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def archived?(entry)
|
|
114
|
+
relative = entry.directory.sub(%r{\A.*/\.ace-tasks/}, "")
|
|
115
|
+
relative.split("/").any? { |segment| segment.start_with?("_") }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Worktree
|
|
6
|
+
module Molecules
|
|
7
|
+
# Renders the listing header, empty-state line, and two-line count footer.
|
|
8
|
+
class WorktreeFooterFormatter
|
|
9
|
+
# @param window_label [String] canonical activity window (e.g. "18h")
|
|
10
|
+
# @param full [Boolean] whether every worktree is listed
|
|
11
|
+
def initialize(window_label:, full: false)
|
|
12
|
+
@window_label = window_label
|
|
13
|
+
@full = full
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [String] e.g. "Active worktrees (last 18h) -- 3 of 88:"
|
|
17
|
+
def header(active_count:, total_count:)
|
|
18
|
+
"Active worktrees (last #{@window_label}) -- #{active_count} of #{total_count}:"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String] empty-state line for a zero-active window
|
|
22
|
+
def no_active_message
|
|
23
|
+
"No active worktrees in the last #{@window_label}."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Array<String>] the count footer lines
|
|
27
|
+
def lines(inactive:, task_associated:, usable:, recent_branches:)
|
|
28
|
+
inactive_line = if @full
|
|
29
|
+
"Inactive: #{inactive}"
|
|
30
|
+
else
|
|
31
|
+
"Inactive: #{inactive} (use --full to list all)"
|
|
32
|
+
end
|
|
33
|
+
[
|
|
34
|
+
inactive_line,
|
|
35
|
+
"Task-associated: #{task_associated} Usable: #{usable} " \
|
|
36
|
+
"Recent branches without worktrees: #{recent_branches}"
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -15,9 +15,6 @@ module Ace
|
|
|
15
15
|
#
|
|
16
16
|
# @example Find worktree by task ID
|
|
17
17
|
# worktree = lister.find_by_task_id("081")
|
|
18
|
-
#
|
|
19
|
-
# @example List with task associations
|
|
20
|
-
# worktrees = lister.list_with_tasks
|
|
21
18
|
class WorktreeLister
|
|
22
19
|
# Fallback timeout for git commands
|
|
23
20
|
# Used only when config is unavailable
|
|
@@ -42,6 +39,18 @@ module Ace
|
|
|
42
39
|
|
|
43
40
|
public
|
|
44
41
|
|
|
42
|
+
# Read the worktree inventory.
|
|
43
|
+
#
|
|
44
|
+
# @return [Hash] `{success:, worktrees:}` or `{success: false, error:}`
|
|
45
|
+
def inventory
|
|
46
|
+
result = Atoms::GitCommand.worktree("list", "--porcelain", timeout: @timeout)
|
|
47
|
+
unless result[:success]
|
|
48
|
+
return {success: false, error: result[:error].to_s.empty? ? "git worktree list failed" : result[:error]}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{success: true, worktrees: Models::WorktreeInfo.from_git_output_list(result[:output])}
|
|
52
|
+
end
|
|
53
|
+
|
|
45
54
|
# List all worktrees in the repository
|
|
46
55
|
#
|
|
47
56
|
# @return [Array<WorktreeInfo>] Array of worktree information
|
|
@@ -51,22 +60,8 @@ module Ace
|
|
|
51
60
|
# worktrees = lister.list_all
|
|
52
61
|
# worktrees.each { |wt| puts "#{wt.branch} at #{wt.path}" }
|
|
53
62
|
def list_all
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Models::WorktreeInfo.from_git_output_list(output)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# List worktrees with task associations resolved
|
|
61
|
-
#
|
|
62
|
-
# @return [Array<WorktreeInfo>] Array of worktree information with task IDs
|
|
63
|
-
#
|
|
64
|
-
# @example
|
|
65
|
-
# worktrees = lister.list_with_tasks
|
|
66
|
-
# worktrees.each { |wt| puts "Task #{wt.task_id}: #{wt.branch}" if wt.task_associated? }
|
|
67
|
-
def list_with_tasks
|
|
68
|
-
worktrees = list_all
|
|
69
|
-
resolve_task_associations(worktrees)
|
|
63
|
+
result = inventory
|
|
64
|
+
result[:success] ? result[:worktrees] : []
|
|
70
65
|
end
|
|
71
66
|
|
|
72
67
|
# Find worktree by task ID
|
|
@@ -80,7 +75,7 @@ module Ace
|
|
|
80
75
|
# puts "Found worktree for task 081: #{worktree.path}"
|
|
81
76
|
# end
|
|
82
77
|
def find_by_task_id(task_id)
|
|
83
|
-
worktrees =
|
|
78
|
+
worktrees = list_all
|
|
84
79
|
Models::WorktreeInfo.find_by_task_id(worktrees, task_id.to_s)
|
|
85
80
|
end
|
|
86
81
|
|
|
@@ -191,7 +186,7 @@ module Ace
|
|
|
191
186
|
def search(query, search_in: [:branch, :path, :task_id])
|
|
192
187
|
return [] if query.nil? || query.empty?
|
|
193
188
|
|
|
194
|
-
worktrees =
|
|
189
|
+
worktrees = list_all
|
|
195
190
|
pattern = Regexp.new(query, Regexp::IGNORECASE)
|
|
196
191
|
|
|
197
192
|
worktrees.select do |worktree|
|
|
@@ -221,7 +216,7 @@ module Ace
|
|
|
221
216
|
# puts "Task-associated: #{stats[:task_associated]}"
|
|
222
217
|
# puts "Usable: #{stats[:usable]}"
|
|
223
218
|
def get_statistics(worktrees = nil)
|
|
224
|
-
worktrees = worktrees ? Array(worktrees) :
|
|
219
|
+
worktrees = worktrees ? Array(worktrees) : list_all
|
|
225
220
|
|
|
226
221
|
{
|
|
227
222
|
total: worktrees.length,
|
|
@@ -236,92 +231,26 @@ module Ace
|
|
|
236
231
|
}
|
|
237
232
|
end
|
|
238
233
|
|
|
239
|
-
# Format worktree list for
|
|
234
|
+
# Format worktree list for non-report consumers.
|
|
240
235
|
#
|
|
241
236
|
# @param worktrees [Array<WorktreeInfo>] Worktrees to format
|
|
242
|
-
# @param format [Symbol] Output format (:
|
|
237
|
+
# @param format [Symbol] Output format (:json, :simple)
|
|
243
238
|
# @return [String] Formatted output
|
|
244
239
|
#
|
|
245
240
|
# @example
|
|
246
|
-
# output = lister.format_for_display(worktrees, :
|
|
241
|
+
# output = lister.format_for_display(worktrees, :simple)
|
|
247
242
|
# puts output
|
|
248
|
-
def format_for_display(worktrees, format = :
|
|
243
|
+
def format_for_display(worktrees, format = :simple)
|
|
249
244
|
case format
|
|
250
|
-
when :table
|
|
251
|
-
format_as_table(worktrees)
|
|
252
245
|
when :json
|
|
253
246
|
format_as_json(worktrees)
|
|
254
|
-
when :simple
|
|
255
|
-
format_as_simple(worktrees)
|
|
256
247
|
else
|
|
257
|
-
|
|
248
|
+
format_as_simple(worktrees)
|
|
258
249
|
end
|
|
259
250
|
end
|
|
260
251
|
|
|
261
252
|
private
|
|
262
253
|
|
|
263
|
-
# Execute git worktree list command
|
|
264
|
-
#
|
|
265
|
-
# @return [String, nil] Command output or nil if failed
|
|
266
|
-
def execute_git_worktree_list
|
|
267
|
-
require_relative "../atoms/git_command"
|
|
268
|
-
result = Atoms::GitCommand.worktree("list", "--porcelain", timeout: @timeout)
|
|
269
|
-
result[:success] ? result[:output] : nil
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# Resolve task associations for worktrees
|
|
273
|
-
#
|
|
274
|
-
# @param worktrees [Array<WorktreeInfo>] Worktrees to process
|
|
275
|
-
# @return [Array<WorktreeInfo>] Worktrees with resolved task associations
|
|
276
|
-
def resolve_task_associations(worktrees)
|
|
277
|
-
return worktrees if worktrees.empty?
|
|
278
|
-
|
|
279
|
-
# Try to resolve task associations from worktree metadata
|
|
280
|
-
# This would typically involve looking up task files
|
|
281
|
-
# For now, we'll use the automatic extraction from WorktreeInfo
|
|
282
|
-
|
|
283
|
-
worktrees
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
# Format worktrees as a table
|
|
287
|
-
#
|
|
288
|
-
# @param worktrees [Array<WorktreeInfo>] Worktrees to format
|
|
289
|
-
# @return [String] Table-formatted string
|
|
290
|
-
def format_as_table(worktrees)
|
|
291
|
-
return "No worktrees found.\n" if worktrees.empty?
|
|
292
|
-
|
|
293
|
-
# Calculate column widths
|
|
294
|
-
max_path_width = [worktrees.map { |wt| wt.path.length }.max, 40].max
|
|
295
|
-
max_branch_width = [worktrees.map { |wt| (wt.branch || "detached").length }.max, 15].max
|
|
296
|
-
max_task_width = [worktrees.map { |wt| (wt.task_id || "-").length }.max, 8].max
|
|
297
|
-
|
|
298
|
-
# Build header
|
|
299
|
-
header = sprintf("%-#{max_task_width}s %-#{max_branch_width}s %-#{max_path_width}s %s",
|
|
300
|
-
"Task", "Branch", "Path", "Status")
|
|
301
|
-
separator = "-" * header.length
|
|
302
|
-
|
|
303
|
-
# Build table rows
|
|
304
|
-
rows = worktrees.map do |wt|
|
|
305
|
-
status = if wt.bare
|
|
306
|
-
"bare"
|
|
307
|
-
elsif wt.detached
|
|
308
|
-
"detached"
|
|
309
|
-
elsif wt.task_associated?
|
|
310
|
-
"task"
|
|
311
|
-
else
|
|
312
|
-
"normal"
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
sprintf("%-#{max_task_width}s %-#{max_branch_width}s %-#{max_path_width}s %s",
|
|
316
|
-
wt.task_id || "-",
|
|
317
|
-
wt.branch || "detached",
|
|
318
|
-
wt.path,
|
|
319
|
-
status)
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
[header, separator, *rows].join("\n") + "\n"
|
|
323
|
-
end
|
|
324
|
-
|
|
325
254
|
# Format worktrees as JSON
|
|
326
255
|
#
|
|
327
256
|
# @param worktrees [Array<WorktreeInfo>] Worktrees to format
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Git
|
|
8
|
+
module Worktree
|
|
9
|
+
module Molecules
|
|
10
|
+
# Read-only inspection of one worktree, designed to run inside a forked
|
|
11
|
+
# probe child (see {Organisms::WorktreeProbeRunner}).
|
|
12
|
+
#
|
|
13
|
+
# The probe reports raw signals only: HEAD commit metadata, uncommitted
|
|
14
|
+
# change counts, task files changed in the worktree, and the resolved
|
|
15
|
+
# `_current` symlink target. The parent process owns clock reads,
|
|
16
|
+
# rendering, and task/PR resolution so ordering and ages stay
|
|
17
|
+
# deterministic.
|
|
18
|
+
#
|
|
19
|
+
# All git calls pass the worktree explicitly via `-C <path>` (ADR-031);
|
|
20
|
+
# the probe never changes the parent process working directory and
|
|
21
|
+
# never writes repository state.
|
|
22
|
+
class WorktreeProbe
|
|
23
|
+
# Marker for a task-tree-relative path prefix.
|
|
24
|
+
TASK_TREE_PREFIX = ".ace-tasks/"
|
|
25
|
+
|
|
26
|
+
# @param project_root [String] primary project root (contains `.ace-tasks/`)
|
|
27
|
+
# @param base_ref [String, nil] primary worktree HEAD sha used as the
|
|
28
|
+
# merge-base reference for committed task-file diffs
|
|
29
|
+
def initialize(project_root:, base_ref: nil)
|
|
30
|
+
@project_root = project_root
|
|
31
|
+
@base_ref = base_ref
|
|
32
|
+
@task_root = project_root ? File.join(project_root, ".ace-tasks") : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Probe one worktree.
|
|
36
|
+
#
|
|
37
|
+
# @param path [String] worktree directory
|
|
38
|
+
# @return [Hash, nil] serializable probe payload, or nil when the
|
|
39
|
+
# worktree cannot be inspected (missing path / git failure)
|
|
40
|
+
def probe(path)
|
|
41
|
+
return nil unless path && File.directory?(path)
|
|
42
|
+
|
|
43
|
+
head = head_metadata(path)
|
|
44
|
+
status = status_snapshot(path)
|
|
45
|
+
return nil if status.nil?
|
|
46
|
+
|
|
47
|
+
committed_task_files = committed_task_paths(path, head && head[:sha])
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
head_sha: head && head[:sha],
|
|
51
|
+
head_committer_epoch: head && head[:committer_epoch],
|
|
52
|
+
head_subject: head && head[:subject],
|
|
53
|
+
staged: status[:staged],
|
|
54
|
+
unstaged: status[:unstaged],
|
|
55
|
+
untracked: status[:untracked],
|
|
56
|
+
uncommitted_paths: status[:paths],
|
|
57
|
+
changed_task_files: status[:paths].select { |p| task_tree_path?(p) },
|
|
58
|
+
committed_task_files: committed_task_files,
|
|
59
|
+
symlink_target: current_task_target(path)
|
|
60
|
+
}
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Resolve the `_current` symlink at a worktree root when it points
|
|
66
|
+
# into the project task tree.
|
|
67
|
+
#
|
|
68
|
+
# @param path [String] worktree directory
|
|
69
|
+
# @return [String, nil] absolute task directory
|
|
70
|
+
def current_task_target(path)
|
|
71
|
+
symlink = File.join(path, "_current")
|
|
72
|
+
return nil unless File.symlink?(symlink) && @task_root
|
|
73
|
+
|
|
74
|
+
target = canonical_path(File.expand_path(File.readlink(symlink), path))
|
|
75
|
+
root = canonical_path(@task_root)
|
|
76
|
+
target.start_with?("#{root}/") ? target : nil
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Canonical comparison form for paths that may be reached through a
|
|
82
|
+
# symlinked root (e.g. macOS /var -> /private/var).
|
|
83
|
+
def canonical_path(value)
|
|
84
|
+
File.realpath(value)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
File.expand_path(value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Deep-scan fallback for the task tree only: untracked files under
|
|
90
|
+
# `.ace-tasks/`. This walk is expensive on large trees, so the probe
|
|
91
|
+
# itself never performs it; callers run it selectively (second round)
|
|
92
|
+
# for worktrees the cheap association tiers could not resolve.
|
|
93
|
+
#
|
|
94
|
+
# @param path [String] worktree directory
|
|
95
|
+
# @return [Array<String>] repo-relative untracked task paths
|
|
96
|
+
def untracked_task_files(path)
|
|
97
|
+
return [] unless path && File.directory?(path)
|
|
98
|
+
|
|
99
|
+
output = git(path, "ls-files", "--others", "--exclude-standard", "--", TASK_TREE_PREFIX)
|
|
100
|
+
return [] if output.nil?
|
|
101
|
+
|
|
102
|
+
output.lines.map { |line| scrub(line.chomp) }.reject(&:empty?).select { |p| task_tree_path?(p) }
|
|
103
|
+
rescue StandardError
|
|
104
|
+
[]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# @return [Hash, nil] `{sha:, committer_epoch:, subject:}` for HEAD
|
|
110
|
+
def head_metadata(path)
|
|
111
|
+
output = git(path, "log", "-1", "--format=%H%x1f%ct%x1f%s")
|
|
112
|
+
return nil if output.nil?
|
|
113
|
+
|
|
114
|
+
sha, epoch, subject = output.chomp.split("\x1f", 3)
|
|
115
|
+
return nil if sha.to_s.empty?
|
|
116
|
+
|
|
117
|
+
{sha: sha, committer_epoch: Integer(epoch, 10), subject: subject.to_s}
|
|
118
|
+
rescue ArgumentError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Hash, nil] staged/unstaged counts and changed paths
|
|
123
|
+
# (tracked files only; untracked discovery is opt-in via
|
|
124
|
+
# {#untracked_task_files})
|
|
125
|
+
def status_snapshot(path)
|
|
126
|
+
output = git(path, "status", "--porcelain=v1", "-z", "--untracked-files=no")
|
|
127
|
+
return nil if output.nil?
|
|
128
|
+
|
|
129
|
+
staged = 0
|
|
130
|
+
unstaged = 0
|
|
131
|
+
untracked = 0
|
|
132
|
+
paths = []
|
|
133
|
+
records = output.split("\x00")
|
|
134
|
+
index = 0
|
|
135
|
+
|
|
136
|
+
while index < records.length
|
|
137
|
+
record = records[index]
|
|
138
|
+
index += 1
|
|
139
|
+
next if record.nil? || record.length < 4
|
|
140
|
+
|
|
141
|
+
xy = record[0, 2]
|
|
142
|
+
file = scrub(record[3..].to_s)
|
|
143
|
+
# Rename/copy records carry the original path in the next record.
|
|
144
|
+
index += 1 if xy[0] == "R" || xy[0] == "C"
|
|
145
|
+
|
|
146
|
+
if xy == "??"
|
|
147
|
+
untracked += 1
|
|
148
|
+
else
|
|
149
|
+
staged += 1 unless xy[0] == " " || xy[0] == "?"
|
|
150
|
+
unstaged += 1 unless xy[1] == " " || xy[1] == "?"
|
|
151
|
+
end
|
|
152
|
+
paths << file
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
{staged: staged, unstaged: unstaged, untracked: untracked, paths: paths}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Task-tree files changed by this worktree's commits, relative to the
|
|
159
|
+
# merge-base with the primary worktree. Falls back to the HEAD
|
|
160
|
+
# commit's changed files when no merge-base exists.
|
|
161
|
+
#
|
|
162
|
+
# @return [Array<String>] repo-relative `.ace-tasks/` paths
|
|
163
|
+
def committed_task_paths(path, head_sha)
|
|
164
|
+
return [] if head_sha.nil?
|
|
165
|
+
|
|
166
|
+
merge_base = @base_ref ? git(path, "merge-base", @base_ref, head_sha)&.strip : nil
|
|
167
|
+
output = if merge_base && !merge_base.empty?
|
|
168
|
+
git(path, "diff", "--name-only", merge_base, head_sha, "--", ".ace-tasks")
|
|
169
|
+
else
|
|
170
|
+
git(path, "diff-tree", "--no-commit-id", "--name-only", "-r", head_sha, "--", ".ace-tasks")
|
|
171
|
+
end
|
|
172
|
+
return [] if output.nil?
|
|
173
|
+
|
|
174
|
+
output.lines.map { |line| scrub(line.chomp) }.reject(&:empty?).select { |p| task_tree_path?(p) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def task_tree_path?(relative_path)
|
|
178
|
+
relative_path.start_with?(TASK_TREE_PREFIX)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Run git in the worktree; nil on any failure.
|
|
182
|
+
#
|
|
183
|
+
# @return [String, nil]
|
|
184
|
+
def git(path, *args)
|
|
185
|
+
stdout, _stderr, status = Open3.capture3("git", "-C", path, *args)
|
|
186
|
+
status.success? ? stdout : nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def scrub(value)
|
|
190
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
File without changes
|