ace-git-worktree 0.26.0 → 0.27.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/git/worktree.yml +2 -2
  3. data/CHANGELOG.md +48 -0
  4. data/docs/getting-started.md +7 -4
  5. data/docs/usage.md +141 -24
  6. data/handbook/agents/worktree.ag.md +10 -6
  7. data/handbook/workflow-instructions/git/worktree-create.wf.md +3 -3
  8. data/handbook/workflow-instructions/git/worktree-manage.wf.md +6 -6
  9. data/handbook/workflow-instructions/git/worktree.wf.md +2 -2
  10. data/lib/ace/git/worktree/atoms/duration_parser.rb +81 -0
  11. data/lib/ace/git/worktree/atoms/relative_age_formatter.rb +59 -0
  12. data/lib/ace/git/worktree/atoms/task_id_extractor.rb +20 -1
  13. data/lib/ace/git/worktree/atoms/text_truncator.rb +67 -0
  14. data/lib/ace/git/worktree/cli/commands/list.rb +29 -8
  15. data/lib/ace/git/worktree/cli.rb +1 -1
  16. data/lib/ace/git/worktree/commands/list_command.rb +90 -94
  17. data/lib/ace/git/worktree/models/task_index.rb +171 -0
  18. data/lib/ace/git/worktree/models/worktree_info.rb +52 -3
  19. data/lib/ace/git/worktree/molecules/pr_status_resolver.rb +172 -0
  20. data/lib/ace/git/worktree/molecules/recent_branch_counter.rb +70 -0
  21. data/lib/ace/git/worktree/molecules/task_association_resolver.rb +121 -0
  22. data/lib/ace/git/worktree/molecules/task_committer.rb +31 -31
  23. data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -0
  24. data/lib/ace/git/worktree/molecules/worktree_footer_formatter.rb +43 -0
  25. data/lib/ace/git/worktree/molecules/worktree_lister.rb +22 -93
  26. data/lib/ace/git/worktree/molecules/worktree_probe.rb +176 -0
  27. data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +236 -0
  28. data/lib/ace/git/worktree/organisms/worktree_list_report.rb +277 -0
  29. data/lib/ace/git/worktree/organisms/worktree_manager.rb +22 -20
  30. data/lib/ace/git/worktree/organisms/worktree_probe_runner.rb +320 -0
  31. data/lib/ace/git/worktree/version.rb +1 -1
  32. data/lib/ace/git/worktree.rb +14 -0
  33. metadata +15 -2
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Worktree
6
+ module Atoms
7
+ # Truncates single-column text using the single-character ellipsis `…`.
8
+ #
9
+ # Each helper returns a string no wider than `width` columns (the
10
+ # ellipsis counts as one column). Text that already fits is returned
11
+ # unchanged. Widths below 1 render an empty string; width 1 renders
12
+ # only the ellipsis.
13
+ #
14
+ # @example Branches keep both ends
15
+ # TextTruncator.middle("codex/ei9-campaign-composition-join", 25)
16
+ # # => "codex/ei9-ca…osition-join"
17
+ module TextTruncator
18
+ ELLIPSIS = "…"
19
+
20
+ class << self
21
+ # Keep the prefix and suffix; elide the middle.
22
+ # @param text [String, nil]
23
+ # @param width [Integer] target column width
24
+ # @return [String]
25
+ def middle(text, width)
26
+ value = text.to_s
27
+ return "" if width <= 0
28
+ return value if value.length <= width
29
+ return ELLIPSIS if width == 1
30
+
31
+ remaining = width - 1
32
+ prefix_length = (remaining + 1) / 2
33
+ suffix_length = remaining - prefix_length
34
+ "#{value[0, prefix_length]}#{ELLIPSIS}#{value[-suffix_length, suffix_length]}"
35
+ end
36
+
37
+ # Keep the prefix; truncate the tail.
38
+ # @param text [String, nil]
39
+ # @param width [Integer] target column width
40
+ # @return [String]
41
+ def right(text, width)
42
+ value = text.to_s
43
+ return "" if width <= 0
44
+ return value if value.length <= width
45
+ return ELLIPSIS if width == 1
46
+
47
+ "#{value[0, width - 1]}#{ELLIPSIS}"
48
+ end
49
+
50
+ # Keep the suffix; elide the head.
51
+ # @param text [String, nil]
52
+ # @param width [Integer] target column width
53
+ # @return [String]
54
+ def left(text, width)
55
+ value = text.to_s
56
+ return "" if width <= 0
57
+ return value if value.length <= width
58
+ return ELLIPSIS if width == 1
59
+
60
+ "#{ELLIPSIS}#{value[-(width - 1), width - 1]}"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -12,28 +12,42 @@ module Ace
12
12
  class List < Ace::Support::Cli::Command
13
13
  include SharedHelpers
14
14
 
15
- desc "List all worktrees with optional filtering"
15
+ desc <<~DESC.chomp
16
+ List active worktrees with task, activity, dirty-state, and PR context
17
+ Columns: Task, Branch, Age, Commit, Changes, PR, Path. A worktree is
18
+ active when its last commit is inside --active-within (default 18h) or
19
+ it has uncommitted changes. Task ids are inferred from recorded task
20
+ metadata, the _current symlink, validated path/branch names, or
21
+ .ace-tasks files changed in the worktree; PRs match by branch, then
22
+ HEAD commit sha, then a unique task reference. Columns shrink to the
23
+ terminal width and Path drops on very narrow terminals. Probing runs
24
+ in isolated child processes with a 5 second per-worktree budget;
25
+ unreadable probes render "?".
26
+ DESC
16
27
 
17
28
  example [
18
- " # List all worktrees",
19
- "--show-tasks # Include task associations",
20
- "--format json # JSON output",
29
+ " # Worktrees active in the last 18h",
30
+ "--full # Every worktree with absolute paths",
31
+ "--active-within 3d # Wider activity window",
32
+ "--format json # Machine-readable output",
21
33
  "--search auth # Filter by branch pattern"
22
34
  ]
23
35
 
24
36
  option :format, desc: "Output format: table, json, simple", aliases: [], default: "table"
25
- option :show_tasks, desc: "Include task associations", type: :boolean
37
+ option :compact, desc: "Active worktrees only (default)", type: :boolean
38
+ option :full, desc: "List every worktree with absolute paths", type: :boolean
39
+ option :active_within, desc: "Activity window (Nm, Nh, Nd, or bare hours; default 18h)", aliases: []
26
40
  option :task_associated, desc: "Show only task-associated worktrees", type: :boolean
27
41
  option :usable, desc: "Show only usable worktrees", type: :boolean
28
42
  option :search, desc: "Filter by branch name pattern", aliases: []
29
- option :quiet, type: :boolean, aliases: ["-q"], desc: "Suppress non-essential output"
43
+ option :quiet, type: :boolean, aliases: ["-q"], desc: "Suppress the count footer"
30
44
  option :verbose, type: :boolean, aliases: ["-v"], desc: "Show verbose output"
31
45
  option :debug, type: :boolean, aliases: ["-d"], desc: "Show debug output"
32
46
 
33
47
  def call(**options)
34
48
  display_config_summary("list", options) unless options[:format] == "json"
35
49
 
36
- # Keep explicit false values as --no-* flags so legacy parser receives filters.
50
+ # Keep explicit false values as --no-* flags so the parser receives filters.
37
51
  args = list_options_to_args(options)
38
52
 
39
53
  Ace::Git::Worktree::Commands::ListCommand.new.run(args)
@@ -44,7 +58,14 @@ module Ace
44
58
  def list_options_to_args(options)
45
59
  args = []
46
60
  args.concat(format_arg(options))
47
- args << "--show-tasks" if options[:show_tasks]
61
+ args << "--compact" if options[:compact]
62
+ args << "--full" if options[:full]
63
+ args << "--quiet" if options[:quiet]
64
+
65
+ if options[:active_within].is_a?(String) && !options[:active_within].empty?
66
+ args << "--active-within"
67
+ args << options[:active_within]
68
+ end
48
69
 
49
70
  unless options[:task_associated].nil?
50
71
  args << (options[:task_associated] ? "--task-associated" : "--no-task-associated")
@@ -38,7 +38,7 @@ module Ace
38
38
 
39
39
  HELP_EXAMPLES = [
40
40
  "ace-git-worktree create --task 148 # Isolated worktree for task",
41
- "ace-git-worktree list --show-tasks # Worktrees with task context",
41
+ "ace-git-worktree list --full # Every worktree with task context",
42
42
  "ace-git-worktree switch 148 # Get path for cd",
43
43
  "ace-git-worktree prune --dry-run # Preview stale cleanup"
44
44
  ].freeze
@@ -6,21 +6,29 @@ module Ace
6
6
  module Commands
7
7
  # List command
8
8
  #
9
- # Lists worktrees with various formatting and filtering options.
10
- # Supports task-aware listing and search capabilities.
9
+ # Prints the active-worktree table: one enriched row per worktree whose
10
+ # last activity falls inside the activity window (committed or dirty),
11
+ # plus a two-line count footer. `--full` lists every worktree,
12
+ # `--format json` is the machine surface, `--format simple` is the
13
+ # branch-at-path listing.
11
14
  #
12
- # @example List all worktrees
15
+ # @example Daily driver (last 18h)
13
16
  # ListCommand.new.run([])
14
17
  #
15
- # @example List with JSON output
16
- # ListCommand.new.run(["--format", "json"])
18
+ # @example Wider window in JSON
19
+ # ListCommand.new.run(["--active-within", "3d", "--format", "json"])
17
20
  #
18
- # @example List only task-associated worktrees
19
- # ListCommand.new.run(["--task-associated"])
21
+ # @example Exhaustive listing
22
+ # ListCommand.new.run(["--full"])
20
23
  class ListCommand
24
+ # Supported output formats
25
+ FORMATS = %w[table json simple].freeze
26
+
21
27
  # Initialize a new ListCommand
22
- def initialize
23
- @manager = Organisms::WorktreeManager.new
28
+ #
29
+ # @param reporter [Organisms::WorktreeListReport, nil] injectable report
30
+ def initialize(reporter: nil)
31
+ @reporter = reporter
24
32
  end
25
33
 
26
34
  # Run the list command
@@ -33,13 +41,10 @@ module Ace
33
41
 
34
42
  validate_options(options)
35
43
 
36
- # Convert format to symbol for WorktreeLister compatibility
37
- options[:format] = options[:format].to_sym if options[:format]
38
-
39
- result = @manager.list_all(options)
44
+ result = reporter.run(options)
40
45
 
41
46
  if result[:success]
42
- display_list_result(result, options)
47
+ puts result[:output] unless result[:output].to_s.empty?
43
48
  0
44
49
  else
45
50
  puts "Failed to list worktrees: #{result[:error]}"
@@ -47,8 +52,6 @@ module Ace
47
52
  end
48
53
  rescue ArgumentError => e
49
54
  puts "Error: #{e.message}"
50
- puts
51
- show_help
52
55
  1
53
56
  rescue => e
54
57
  puts "Error: #{e.message}"
@@ -63,49 +66,61 @@ module Ace
63
66
  ace-git-worktree list - List worktrees
64
67
 
65
68
  USAGE:
66
- ace-git-worktree list [OPTIONS]
69
+ ace-git-worktree list [--compact | --full] [--active-within DURATION] [OPTIONS]
70
+
71
+ VIEW:
72
+ --compact Active worktrees only (default)
73
+ --full List every worktree with absolute paths
74
+ --active-within <dur> Activity window: Nm, Nh, Nd, or bare hours (default: 18h)
67
75
 
68
- OUTPUT FORMATS:
76
+ OUTPUT:
69
77
  --format <format> Output format: table, json, simple (default: table)
70
- --show-tasks Include task associations
78
+ -q, --quiet Suppress the count footer
71
79
 
72
80
  FILTERING:
73
- --task-associated Show only task-associated worktrees
74
- --no-task-associated Show only non-task worktrees
75
- --usable Show only usable worktrees
76
- --no-usable Show only unusable worktrees
81
+ --task-associated Show only worktrees with a resolved task id
82
+ --no-task-associated Show only worktrees without a task id
83
+ --usable Show only usable worktrees
84
+ --no-usable Show only unusable worktrees
77
85
  --search <pattern> Filter by branch name pattern
78
86
 
79
87
  EXAMPLES:
80
- # List all worktrees in table format
88
+ # Worktrees active in the last 18 hours
81
89
  ace-git-worktree list
82
90
 
83
- # List with task associations in JSON format
84
- ace-git-worktree list --show-tasks --format json
85
-
86
- # List only task-associated worktrees
87
- ace-git-worktree list --task-associated
88
-
89
- # Search for worktrees with "auth" in branch name
90
- ace-git-worktree list --search auth
91
-
92
- # List only usable worktrees
93
- ace-git-worktree list --usable
94
-
95
- OUTPUT:
96
- Table format columns:
97
- - Task: Task ID (or - for non-task worktrees)
98
- - Branch: Git branch name
99
- - Path: Worktree directory path
100
- - Status: worktree status (task, normal, bare, detached, etc.)
101
-
102
- JSON format includes full worktree details and metadata.
91
+ # Everything, including inactive worktrees
92
+ ace-git-worktree list --full
93
+
94
+ # After a weekend
95
+ ace-git-worktree list --active-within 3d
96
+
97
+ # Machine-readable output
98
+ ace-git-worktree list --format json
99
+
100
+ COLUMNS:
101
+ Task Resolved task id (inferred from metadata, the _current symlink,
102
+ validated names, or task files changed in the worktree)
103
+ Branch Checked-out branch (middle-elided when truncated)
104
+ Age Age of the last commit, one compact unit (8m, 3h, 9d, 3w, 2mo, 1y)
105
+ Commit Last commit subject (right-truncated)
106
+ Changes Uncommitted file count (staged + unstaged + untracked)
107
+ PR #N, #N draft, #N merged, #N closed, or - (matched by branch,
108
+ HEAD commit, or a unique task reference)
109
+ Path Worktree path (~ shortened; --full prints it absolute)
110
+
111
+ Columns shrink to fit the terminal width; Path is dropped on very
112
+ narrow terminals. Probing runs in isolated child processes with a
113
+ 5 second per-worktree budget; unreadable probes render "?".
103
114
  HELP
104
115
  0
105
116
  end
106
117
 
107
118
  private
108
119
 
120
+ def reporter
121
+ @reporter || Organisms::WorktreeListReport.new(env: ENV)
122
+ end
123
+
109
124
  # Parse command line arguments
110
125
  #
111
126
  # @param args [Array<String>] Command arguments
@@ -113,10 +128,15 @@ module Ace
113
128
  def parse_arguments(args)
114
129
  options = {
115
130
  format: "table",
116
- show_tasks: false,
131
+ compact: false,
132
+ full: false,
133
+ active_within: nil,
117
134
  task_associated: nil,
118
135
  usable: nil,
119
136
  search: nil,
137
+ quiet: false,
138
+ verbose: false,
139
+ debug: false,
120
140
  help: false
121
141
  }
122
142
 
@@ -128,13 +148,17 @@ module Ace
128
148
  when "--format"
129
149
  i += 1
130
150
  format = args[i]&.downcase
131
- if %w[table json simple].include?(format)
132
- options[:format] = format
133
- else
151
+ unless FORMATS.include?(format)
134
152
  raise ArgumentError, "Invalid format: #{format}. Use: table, json, simple"
135
153
  end
136
- when "--show-tasks"
137
- options[:show_tasks] = true
154
+ options[:format] = format
155
+ when "--compact"
156
+ options[:compact] = true
157
+ when "--full"
158
+ options[:full] = true
159
+ when "--active-within"
160
+ i += 1
161
+ options[:active_within] = args[i]
138
162
  when "--task-associated"
139
163
  options[:task_associated] = true
140
164
  when "--no-task-associated"
@@ -146,6 +170,12 @@ module Ace
146
170
  when "--search"
147
171
  i += 1
148
172
  options[:search] = args[i]
173
+ when "--quiet", "-q"
174
+ options[:quiet] = true
175
+ when "--verbose", "-v"
176
+ options[:verbose] = true
177
+ when "--debug", "-d"
178
+ options[:debug] = true
149
179
  when "--help", "-h"
150
180
  options[:help] = true
151
181
  when /^--/
@@ -164,14 +194,23 @@ module Ace
164
194
  #
165
195
  # @param options [Hash] Parsed options
166
196
  def validate_options(options)
197
+ if options[:compact] && options[:full]
198
+ raise ArgumentError, "--compact and --full are mutually exclusive"
199
+ end
200
+
167
201
  if options[:search] && options[:search].empty?
168
202
  raise ArgumentError, "Search pattern cannot be empty"
169
203
  end
170
204
 
171
- if options[:format] && !%w[table json simple].include?(options[:format])
205
+ if options[:format] && !FORMATS.include?(options[:format])
172
206
  raise ArgumentError, "Invalid format: #{options[:format]}"
173
207
  end
174
208
 
209
+ # Parse eagerly so invalid windows fail before any output.
210
+ if options[:active_within]
211
+ Atoms::DurationParser.parse(options[:active_within])
212
+ end
213
+
175
214
  # Security validation for search patterns
176
215
  if options[:search] && contains_dangerous_patterns?(options[:search])
177
216
  raise ArgumentError, "Search pattern contains potentially dangerous characters"
@@ -197,49 +236,6 @@ module Ace
197
236
 
198
237
  dangerous_patterns.any? { |pattern| value.match?(pattern) }
199
238
  end
200
-
201
- # Display list result
202
- #
203
- # @param result [Hash] List result
204
- # @param options [Hash] Command options
205
- def display_list_result(result, options)
206
- if result[:worktrees].empty?
207
- puts "No worktrees found."
208
- return
209
- end
210
-
211
- # Display the formatted output
212
- puts result[:formatted_output]
213
-
214
- # Display summary if requested or if not JSON format
215
- if options[:format] != :json
216
- display_summary(result, options)
217
- end
218
- end
219
-
220
- # Display summary information
221
- #
222
- # @param result [Hash] List result
223
- # @param options [Hash] Command options
224
- def display_summary(result, options)
225
- stats = result[:statistics]
226
- puts "\nSummary:"
227
- puts " Total worktrees: #{stats[:total]}"
228
- puts " Task-associated: #{stats[:task_associated]}"
229
- puts " Usable: #{stats[:usable]}"
230
-
231
- if options[:show_tasks] && stats[:task_ids].any?
232
- puts " Tasks with worktrees: #{stats[:task_ids].join(", ")}"
233
- end
234
-
235
- if stats[:branches].any?
236
- puts " Branches: #{stats[:branches].join(", ")}"
237
- end
238
-
239
- # Show active worktrees count
240
- active_count = result[:worktrees].count { |wt| wt.exists? && wt.usable? }
241
- puts " Active worktrees: #{active_count}"
242
- end
243
239
  end
244
240
  end
245
241
  end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/task_id_extractor"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Models
9
+ # Immutable index of the current project's tasks, built from the
10
+ # `.ace-tasks/` tree once per listing run.
11
+ #
12
+ # The index maps task spec frontmatter ids (full and short form), task
13
+ # directories, and recorded `worktree.path` / `worktree.branch` metadata
14
+ # so the list command can associate worktrees to tasks without relying
15
+ # on worktree names.
16
+ #
17
+ # @example
18
+ # index = TaskIndexBuilder.new.build
19
+ # index.task?("k9y") # => true
20
+ # index.find_by_worktree_branch("codex/k9y-list") # => Entry
21
+ class TaskIndex
22
+ # One indexed task.
23
+ #
24
+ # @!attribute full_id [String] formatted id (e.g. "8xg.t.k9y")
25
+ # @!attribute short_id [String] short id (e.g. "k9y", "qk0.3")
26
+ # @!attribute directory [String] absolute task directory
27
+ # @!attribute spec_path [String] absolute task spec path
28
+ # @!attribute worktree_path [String, nil] absolute recorded worktree path
29
+ # @!attribute worktree_branch [String, nil] recorded worktree branch
30
+ Entry = Data.define(:full_id, :short_id, :directory, :spec_path, :worktree_path, :worktree_branch)
31
+
32
+ # @param entries [Array<Entry>] indexed tasks (unique short ids)
33
+ # @param available [Boolean] whether a `.ace-tasks/` tree was found
34
+ # @param project_root [String] project root the index was built from
35
+ def initialize(entries: [], available: false, project_root: nil)
36
+ @entries = entries.freeze
37
+ @available = available
38
+ @project_root = project_root
39
+ end
40
+
41
+ attr_reader :entries, :project_root
42
+
43
+ # Whether the project has a `.ace-tasks/` tree.
44
+ # @return [Boolean]
45
+ def available?
46
+ @available
47
+ end
48
+
49
+ # @return [Boolean]
50
+ def empty?
51
+ @entries.empty?
52
+ end
53
+
54
+ # Find an entry by short or full id.
55
+ #
56
+ # @param reference [String, nil]
57
+ # @return [Entry, nil]
58
+ def entry_for_reference(reference)
59
+ token = reference.to_s.strip
60
+ return nil if token.empty?
61
+
62
+ @entries.find { |entry| entry.full_id == token || entry.short_id == token }
63
+ end
64
+
65
+ # Whether the reference resolves to a known task.
66
+ #
67
+ # @param reference [String, nil]
68
+ # @return [Boolean]
69
+ def task?(reference)
70
+ !entry_for_reference(reference).nil?
71
+ end
72
+
73
+ # Resolve an arbitrary string to a short task id when it names a known
74
+ # task (extracting a task reference from it first).
75
+ #
76
+ # @param reference [String, nil]
77
+ # @return [String, nil]
78
+ def short_id_for(reference)
79
+ entry = entry_for_reference(reference)
80
+ return entry.short_id if entry
81
+
82
+ extracted = Atoms::TaskIDExtractor.normalize(reference)
83
+ extracted && task?(extracted) ? extracted : nil
84
+ end
85
+
86
+ # Find the task whose recorded worktree path matches.
87
+ #
88
+ # @param path [String, nil]
89
+ # @return [Entry, nil]
90
+ def find_by_worktree_path(path)
91
+ return nil if path.to_s.empty?
92
+
93
+ expanded = canonical_path(path)
94
+ @entries.find do |entry|
95
+ entry.worktree_path && canonical_path(entry.worktree_path) == expanded
96
+ end
97
+ end
98
+
99
+ # Find the task whose recorded worktree branch matches.
100
+ #
101
+ # @param branch [String, nil]
102
+ # @return [Entry, nil]
103
+ def find_by_worktree_branch(branch)
104
+ return nil if branch.to_s.empty?
105
+
106
+ @entries.find { |entry| entry.worktree_branch == branch }
107
+ end
108
+
109
+ # Find the task owning an absolute task directory.
110
+ #
111
+ # @param directory [String, nil]
112
+ # @return [Entry, nil]
113
+ def find_by_directory(directory)
114
+ return nil if directory.to_s.empty?
115
+
116
+ expanded = canonical_path(directory)
117
+ @entries.find { |entry| canonical_path(entry.directory) == expanded }
118
+ end
119
+
120
+ # Map changed file paths to task candidates.
121
+ #
122
+ # Paths are relative to the project root (as produced by git). Files
123
+ # changed under a task directory count towards that task; for a
124
+ # changed spec file the candidate id still comes from its directory.
125
+ #
126
+ # @param paths [Array<String>] repo-relative changed paths
127
+ # @return [Array<Hash>] `[{entry:, count:}]` sorted by count desc then
128
+ # short id asc
129
+ def candidates_for_paths(paths)
130
+ counts = Hash.new(0)
131
+ Array(paths).each do |relative_path|
132
+ next if relative_path.to_s.empty?
133
+
134
+ absolute = canonical_path(File.expand_path(relative_path, @project_root || Dir.pwd))
135
+ entry = find_by_enclosing_directory(absolute)
136
+ counts[entry] += 1 if entry
137
+ end
138
+
139
+ counts.map { |entry, count| {entry: entry, count: count} }
140
+ .sort_by { |candidate| [-candidate[:count], candidate[:entry].short_id] }
141
+ end
142
+
143
+ private
144
+
145
+ # Locate the indexed task directory containing a file path (any depth
146
+ # under the task directory, including nested spec/artifact folders).
147
+ def find_by_enclosing_directory(absolute_path)
148
+ @entries.find do |entry|
149
+ directory = canonical_path(entry.directory)
150
+ absolute_path == directory || absolute_path.start_with?("#{directory}/")
151
+ end
152
+ end
153
+
154
+ # Canonical comparison form for paths that may be reached through a
155
+ # symlinked root (e.g. macOS /var -> /private/var).
156
+ def canonical_path(value)
157
+ expanded = File.expand_path(value)
158
+ return File.realpath(expanded) if File.exist?(expanded)
159
+
160
+ parent = File.dirname(expanded)
161
+ return expanded if parent == expanded
162
+
163
+ File.join(canonical_path(parent), File.basename(expanded))
164
+ rescue StandardError
165
+ File.expand_path(value)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end