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
@@ -22,7 +22,9 @@ module Ace
22
22
  # task_id: "081"
23
23
  # )
24
24
  class WorktreeInfo
25
- attr_reader :path, :branch, :commit, :task_id, :bare, :detached, :created_at
25
+ attr_reader :path, :branch, :commit, :task_id, :bare, :detached, :created_at,
26
+ :task_id_source, :task_ids, :active_within_window, :last_commit,
27
+ :uncommitted, :pr, :probe_error
26
28
 
27
29
  # Initialize a new WorktreeInfo
28
30
  #
@@ -33,7 +35,17 @@ module Ace
33
35
  # @param bare [Boolean] Whether this is a bare worktree
34
36
  # @param detached [Boolean] Whether worktree is in detached HEAD state
35
37
  # @param created_at [Time, nil] When the worktree was created
36
- def initialize(path:, commit:, branch: nil, task_id: nil, bare: false, detached: false, created_at: nil)
38
+ # @param task_id_source [String, nil] How the task id was resolved
39
+ # (metadata|symlink|path|branch|task_files)
40
+ # @param task_ids [Array<String>] All inferred candidate task ids
41
+ # @param active_within_window [Boolean, nil] activity classification
42
+ # @param last_commit [Hash, nil] `{sha:, age_hours:, committed_at:, subject:}`
43
+ # @param uncommitted [Hash, nil] `{staged:, unstaged:, untracked:, total:}`
44
+ # @param pr [Hash, nil] `{number:, state:, draft:, matched_by:}`
45
+ # @param probe_error [Boolean] whether the per-worktree probe failed
46
+ def initialize(path:, commit:, branch: nil, task_id: nil, bare: false, detached: false, created_at: nil,
47
+ task_id_source: nil, task_ids: [], active_within_window: nil, last_commit: nil,
48
+ uncommitted: nil, pr: nil, probe_error: false)
37
49
  @path = path
38
50
  @branch = branch
39
51
  @commit = commit
@@ -41,6 +53,37 @@ module Ace
41
53
  @bare = bare
42
54
  @detached = detached
43
55
  @created_at = created_at
56
+ @task_id_source = task_id_source
57
+ @task_ids = Array(task_ids).freeze
58
+ @active_within_window = active_within_window
59
+ @last_commit = last_commit
60
+ @uncommitted = uncommitted
61
+ @pr = pr
62
+ @probe_error = probe_error
63
+ end
64
+
65
+ # Return a copy with enrichment fields applied.
66
+ #
67
+ # @param fields [Hash] enrichment overrides
68
+ # @return [WorktreeInfo]
69
+ def with_enrichment(**fields)
70
+ self.class.new(
71
+ path: @path, branch: @branch, commit: @commit, bare: @bare, detached: @detached,
72
+ created_at: @created_at,
73
+ task_id: fields.fetch(:task_id, @task_id),
74
+ task_id_source: fields.fetch(:task_id_source, @task_id_source),
75
+ task_ids: fields.fetch(:task_ids, @task_ids),
76
+ active_within_window: fields.fetch(:active_within_window, @active_within_window),
77
+ last_commit: fields.fetch(:last_commit, @last_commit),
78
+ uncommitted: fields.fetch(:uncommitted, @uncommitted),
79
+ pr: fields.fetch(:pr, @pr),
80
+ probe_error: fields.fetch(:probe_error, @probe_error)
81
+ )
82
+ end
83
+
84
+ # @return [Boolean] true when the per-worktree probe could not run
85
+ def probe_failed?
86
+ @probe_error
44
87
  end
45
88
 
46
89
  # Check if the worktree is associated with a task
@@ -231,13 +274,19 @@ module Ace
231
274
  branch: @branch,
232
275
  commit: @commit,
233
276
  task_id: @task_id,
277
+ task_id_source: @task_id_source,
278
+ task_ids: @task_ids,
234
279
  bare: @bare,
235
280
  detached: @detached,
236
281
  created_at: @created_at,
237
282
  usable: usable?,
238
283
  task_associated: task_associated?,
239
284
  exists: exists?,
240
- empty: empty?
285
+ empty: empty?,
286
+ active_within_window: @active_within_window,
287
+ last_commit: @last_commit,
288
+ uncommitted: @uncommitted,
289
+ pr: @pr
241
290
  }
242
291
  end
243
292
 
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Molecules
9
+ # Resolves the pull request associated with a worktree through the
10
+ # configured forge provider using exactly one bounded recent-PR query
11
+ # per listing run.
12
+ #
13
+ # Resolution order: branch match, then HEAD-sha match, then a unique
14
+ # task-reference match in a PR title/body. Any provider failure is
15
+ # swallowed: PR resolution never fails the command and never blocks
16
+ # output.
17
+ class PrStatusResolver
18
+ # Bounded recent-PR window fetched once per run.
19
+ PR_QUERY_LIMIT = 50
20
+
21
+ # Providers expose PR state as symbols; JSON/table use strings.
22
+ STATE_LABELS = {open: "open", merged: "merged", closed: "closed"}.freeze
23
+ STATE_PRIORITY = {open: 0, merged: 1, closed: 2}.freeze
24
+
25
+ # @param provider [Object, nil] injectable provider (tests)
26
+ # @param query [Proc, nil] injectable `->(limit:) { Array }` query (tests)
27
+ # @param limit [Integer] bounded query size
28
+ # @param timeout [Integer, nil] provider timeout
29
+ def initialize(query: nil, provider: nil, limit: PR_QUERY_LIMIT, timeout: nil)
30
+ @query = query
31
+ @provider = provider
32
+ @limit = limit
33
+ @timeout = timeout
34
+ end
35
+
36
+ # Resolve the PR for one worktree.
37
+ #
38
+ # @param worktree [Models::WorktreeInfo]
39
+ # @param task_id [String, nil] resolved task id
40
+ # @return [Hash, nil] `{number:, state:, draft:, matched_by:}`
41
+ def resolve(worktree, task_id)
42
+ pull_requests = recent_pull_requests
43
+ return nil if pull_requests.empty?
44
+
45
+ match = by_branch(pull_requests, worktree.branch) ||
46
+ by_head_sha(pull_requests, worktree.commit) ||
47
+ by_task_reference(pull_requests, task_id)
48
+ return nil unless match
49
+
50
+ {
51
+ number: match[:pull_request][:number],
52
+ state: STATE_LABELS.fetch(match[:pull_request][:state], match[:pull_request][:state].to_s),
53
+ draft: match[:pull_request][:draft] ? true : false,
54
+ matched_by: match[:matched_by]
55
+ }
56
+ end
57
+
58
+ # Render the PR cell text.
59
+ #
60
+ # @param pr [Hash, nil]
61
+ # @return [String]
62
+ def self.cell(pr)
63
+ return "-" if pr.nil? || pr[:number].nil?
64
+
65
+ label = "##{pr[:number]}"
66
+ case pr[:state].to_s
67
+ when "open" then pr[:draft] ? "#{label} draft" : label
68
+ when "merged" then "#{label} merged"
69
+ when "closed" then "#{label} closed"
70
+ else label
71
+ end
72
+ end
73
+
74
+ # The bounded recent-PR query, executed once per resolver.
75
+ #
76
+ # @return [Array<Hash>] normalized PR hashes
77
+ def recent_pull_requests
78
+ return @recent_pull_requests if defined?(@recent_pull_requests)
79
+
80
+ @recent_pull_requests = begin
81
+ rows = if @query
82
+ @query.call(limit: @limit)
83
+ else
84
+ provider = resolved_provider
85
+ provider ? provider.recent_pull_requests(limit: @limit) : []
86
+ end
87
+ Array(rows).map { |row| normalize(row) }.compact
88
+ rescue StandardError
89
+ []
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def resolved_provider
96
+ return @provider if defined?(@provider) && @provider
97
+
98
+ @provider = begin
99
+ server = Ace::Git::ServerRegistry.resolve_default
100
+ server ? Ace::Git::Providers.for(server, timeout: @timeout) : nil
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ end
105
+
106
+ def by_branch(pull_requests, branch)
107
+ return nil if branch.to_s.empty?
108
+
109
+ candidates = pull_requests.select { |pr| pr[:head_ref] == branch }
110
+ best_match(candidates, "branch")
111
+ end
112
+
113
+ def by_head_sha(pull_requests, commit)
114
+ return nil if commit.to_s.empty?
115
+
116
+ candidates = pull_requests.select { |pr| pr[:head_sha] == commit }
117
+ best_match(candidates, "head_sha")
118
+ end
119
+
120
+ def by_task_reference(pull_requests, task_id)
121
+ return nil if task_id.to_s.empty?
122
+
123
+ pattern = task_reference_pattern(task_id)
124
+ candidates = pull_requests.select do |pr|
125
+ pattern.match?(pr[:title].to_s) || pattern.match?(pr[:body].to_s)
126
+ end
127
+ # Exactly one recent PR may reference the task; ambiguity means no PR.
128
+ return nil unless candidates.length == 1
129
+
130
+ {pull_request: candidates.first, matched_by: "task_reference"}
131
+ end
132
+
133
+ # Prefer open PRs over merged/closed, then the most recent number.
134
+ def best_match(candidates, matched_by)
135
+ return nil if candidates.empty?
136
+
137
+ chosen = candidates.min_by do |pr|
138
+ [STATE_PRIORITY.fetch(pr[:state], STATE_PRIORITY.length), -pr[:number].to_i]
139
+ end
140
+ {pull_request: chosen, matched_by: matched_by}
141
+ end
142
+
143
+ # A bounded token: the task id must not be glued to other
144
+ # alphanumerics ("k9y", and inside "8xg.t.k9y").
145
+ def task_reference_pattern(task_id)
146
+ /(?<![0-9a-z])#{Regexp.escape(task_id)}(?![0-9a-z])/i
147
+ end
148
+
149
+ def normalize(row)
150
+ {
151
+ number: field(row, :number)&.to_i,
152
+ state: field(row, :state)&.to_sym,
153
+ draft: field(row, :draft) ? true : false,
154
+ head_ref: field(row, :head_ref),
155
+ head_sha: field(row, :head_sha),
156
+ title: field(row, :title),
157
+ body: field(row, :body)
158
+ }
159
+ end
160
+
161
+ def field(row, name)
162
+ if row.is_a?(Hash)
163
+ row[name] || row[name.to_s]
164
+ else
165
+ row.respond_to?(name) ? row.public_send(name) : nil
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Molecules
9
+ # Counts branches whose last commit landed inside the activity window but
10
+ # that have no worktree. This count is branch-based: worktree filters
11
+ # never change it.
12
+ class RecentBranchCounter
13
+ # @param worktrees [Array<Models::WorktreeInfo>] selected worktrees
14
+ # @param window_hours [Numeric] activity window in hours
15
+ # @param now [Time] clock snapshot shared with the listing run
16
+ # @param runner [Proc, nil] injectable ref lister returning
17
+ # `"<branch>\0<committer unix>"` lines (tests)
18
+ def initialize(worktrees:, window_hours:, now: Time.now, runner: nil)
19
+ @worktrees = Array(worktrees)
20
+ @window_hours = window_hours
21
+ @now = now
22
+ @runner = runner
23
+ end
24
+
25
+ # @return [Integer]
26
+ def count
27
+ cutoff = @now.to_i - (@window_hours * 3600).to_i
28
+ checked_out = @worktrees.map(&:branch).compact
29
+ primary_branch = @worktrees.first&.branch
30
+
31
+ branch_rows.count do |branch, committed_at|
32
+ next false if branch.nil? || branch.empty?
33
+ next false if branch == primary_branch
34
+ next false if checked_out.include?(branch)
35
+ next false if committed_at.nil?
36
+
37
+ committed_at >= cutoff
38
+ end
39
+ rescue StandardError
40
+ 0
41
+ end
42
+
43
+ private
44
+
45
+ # @return [Array<Array(String, Integer)>] branch/committer pairs
46
+ def branch_rows
47
+ output = @runner ? @runner.call : default_branch_listing
48
+ return [] if output.nil? || output.empty?
49
+
50
+ output.lines.filter_map do |line|
51
+ branch, epoch = line.chomp.split("\x00", 2)
52
+ next if branch.to_s.empty?
53
+
54
+ [branch, epoch && Integer(epoch, 10)]
55
+ rescue ArgumentError
56
+ nil
57
+ end
58
+ end
59
+
60
+ def default_branch_listing
61
+ stdout, _stderr, status = Open3.capture3(
62
+ "git", "for-each-ref", "--format=%(refname:short)%00%(committerdate:unix)", "refs/heads/"
63
+ )
64
+ status.success? ? stdout : ""
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/task_id_extractor"
4
+ require_relative "../models/task_index"
5
+
6
+ module Ace
7
+ module Git
8
+ module Worktree
9
+ module Molecules
10
+ # Resolves a worktree to a task id without depending on worktree names.
11
+ #
12
+ # Precedence (first hit wins):
13
+ # 1. recorded task frontmatter metadata (`worktree.path` / `worktree.branch`);
14
+ # 2. the `_current` symlink at the worktree root resolving under `.ace-tasks/`;
15
+ # 3. a validated path/branch name extraction (rejected when the extracted
16
+ # id names no known task);
17
+ # 4. task files changed inside the worktree (uncommitted first, then
18
+ # committed against the primary branch merge-base);
19
+ # 5. unresolved (`task_id` stays nil -> rendered `-`).
20
+ class TaskAssociationResolver
21
+ # Task-provenance values exposed in JSON `task_id_source`.
22
+ SOURCES = %w[metadata symlink path branch task_files].freeze
23
+
24
+ # @param task_index [Models::TaskIndex]
25
+ def initialize(task_index:)
26
+ @task_index = task_index || Models::TaskIndex.new
27
+ end
28
+
29
+ # @param worktree [Models::WorktreeInfo]
30
+ # @param probe [Hash, nil] probe payload for this worktree
31
+ # @return [Models::WorktreeInfo] copy carrying task resolution fields
32
+ def resolve(worktree, probe)
33
+ resolved = resolve_source(worktree, probe || {})
34
+ worktree.with_enrichment(
35
+ task_id: resolved[:task_id],
36
+ task_id_source: resolved[:source],
37
+ task_ids: resolved[:task_ids]
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def resolve_source(worktree, probe)
44
+ if (entry = recorded_metadata_entry(worktree))
45
+ return resolved_entry(entry, "metadata")
46
+ end
47
+
48
+ if (entry = symlink_entry(probe))
49
+ return resolved_entry(entry, "symlink")
50
+ end
51
+
52
+ if (named = name_extraction(worktree))
53
+ return named
54
+ end
55
+
56
+ if (inferred = task_file_inference(probe))
57
+ return inferred
58
+ end
59
+
60
+ {task_id: nil, source: nil, task_ids: []}
61
+ end
62
+
63
+ def resolved_entry(entry, source)
64
+ {task_id: entry.short_id, source: source, task_ids: [entry.short_id]}
65
+ end
66
+
67
+ def recorded_metadata_entry(worktree)
68
+ @task_index.find_by_worktree_path(worktree.path) ||
69
+ @task_index.find_by_worktree_branch(worktree.branch)
70
+ end
71
+
72
+ def symlink_entry(probe)
73
+ target = probe[:symlink_target]
74
+ return nil if target.to_s.empty?
75
+
76
+ @task_index.find_by_directory(target)
77
+ end
78
+
79
+ # Validate the existing path -> branch name extraction. Without a
80
+ # `.ace-tasks/` tree the extraction is accepted as before; with a task
81
+ # tree, the extracted id must resolve to a known task so meaningless
82
+ # directory names (e.g. "ace") do not pollute the Task column.
83
+ def name_extraction(worktree)
84
+ from_path = Atoms::TaskIDExtractor.normalize(File.basename(worktree.path))
85
+ if from_path && acceptable?(from_path)
86
+ return {task_id: from_path, source: "path", task_ids: [from_path]}
87
+ end
88
+
89
+ from_branch = worktree.branch && Atoms::TaskIDExtractor.normalize(worktree.branch)
90
+ if from_branch && acceptable?(from_branch)
91
+ return {task_id: from_branch, source: "branch", task_ids: [from_branch]}
92
+ end
93
+
94
+ nil
95
+ end
96
+
97
+ def acceptable?(task_id)
98
+ return true unless @task_index.available?
99
+
100
+ @task_index.task?(task_id)
101
+ end
102
+
103
+ def task_file_inference(probe)
104
+ paths = Array(probe[:changed_task_files])
105
+ paths = Array(probe[:committed_task_files]) if paths.empty?
106
+ return nil if paths.empty?
107
+
108
+ candidates = @task_index.candidates_for_paths(paths)
109
+ return nil if candidates.empty?
110
+
111
+ {
112
+ task_id: candidates.first[:entry].short_id,
113
+ source: "task_files",
114
+ task_ids: candidates.map { |candidate| candidate[:entry].short_id }
115
+ }
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -192,6 +192,37 @@ module Ace
192
192
  @ace_git_commit_available = result[:success]
193
193
  end
194
194
 
195
+ # Commit scoped files and return commit metadata + changed path list
196
+ #
197
+ # @param files [Array<String>] Files to commit
198
+ # @param message [String] Commit message
199
+ # @return [Hash] Result with :success, :commit_sha, :committed_paths, :error
200
+ def commit_scoped(files, message)
201
+ existing_files = Array(files).select { |file| File.exist?(file) }
202
+ return {success: false, commit_sha: nil, committed_paths: [], error: "No existing files to commit"} if existing_files.empty?
203
+
204
+ success = commit_with_message(existing_files, message)
205
+ return {success: false, commit_sha: nil, committed_paths: [], error: "Commit failed"} unless success
206
+
207
+ sha_res = execute_git_command("rev-parse", "HEAD")
208
+ sha = sha_res[:success] ? sha_res[:output].strip : nil
209
+
210
+ committed_paths = []
211
+ if sha
212
+ diff_res = execute_git_command("diff-tree", "--no-commit-id", "--name-only", "-r", sha)
213
+ if diff_res[:success]
214
+ committed_paths = diff_res[:output].to_s.lines.map(&:strip).reject(&:empty?)
215
+ end
216
+ end
217
+
218
+ {
219
+ success: true,
220
+ commit_sha: sha,
221
+ committed_paths: committed_paths,
222
+ error: nil
223
+ }
224
+ end
225
+
195
226
  private
196
227
 
197
228
  # Generate a commit message based on status and task ID
@@ -254,37 +285,6 @@ module Ace
254
285
  commit_result[:success]
255
286
  end
256
287
 
257
- # Commit scoped files and return commit metadata + changed path list
258
- #
259
- # @param files [Array<String>] Files to commit
260
- # @param message [String] Commit message
261
- # @return [Hash] Result with :success, :commit_sha, :committed_paths, :error
262
- def commit_scoped(files, message)
263
- existing_files = Array(files).select { |file| File.exist?(file) }
264
- return {success: false, commit_sha: nil, committed_paths: [], error: "No existing files to commit"} if existing_files.empty?
265
-
266
- success = commit_with_message(existing_files, message)
267
- return {success: false, commit_sha: nil, committed_paths: [], error: "Commit failed"} unless success
268
-
269
- sha_res = execute_git_command("rev-parse", "HEAD")
270
- sha = sha_res[:success] ? sha_res[:output].strip : nil
271
-
272
- committed_paths = []
273
- if sha
274
- diff_res = execute_git_command("diff-tree", "--no-commit-id", "--name-only", "-r", sha)
275
- if diff_res[:success]
276
- committed_paths = diff_res[:output].to_s.lines.map(&:strip).reject(&:empty?)
277
- end
278
- end
279
-
280
- {
281
- success: true,
282
- commit_sha: sha,
283
- committed_paths: committed_paths,
284
- error: nil
285
- }
286
- end
287
-
288
288
  # Commit all changes using direct git commands
289
289
  #
290
290
  # @param message [String] Commit message
@@ -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
@@ -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