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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/nav/protocols/skill-sources/ace-git-worktree.yml +0 -0
  3. data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +0 -0
  4. data/CHANGELOG.md +62 -0
  5. data/LICENSE +0 -0
  6. data/README.md +0 -0
  7. data/Rakefile +0 -0
  8. data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
  9. data/docs/demo/ace-git-worktree-getting-started.tape.yml +0 -0
  10. data/docs/demo/fixtures/README.md +0 -0
  11. data/docs/demo/fixtures/sample.txt +0 -0
  12. data/docs/getting-started.md +7 -4
  13. data/docs/handbook.md +0 -0
  14. data/docs/usage.md +153 -24
  15. data/handbook/agents/worktree.ag.md +10 -6
  16. data/handbook/skills/as-git-worktree/SKILL.md +0 -0
  17. data/handbook/skills/as-git-worktree-cleanup/SKILL.md +0 -0
  18. data/handbook/skills/as-git-worktree-create/SKILL.md +0 -0
  19. data/handbook/skills/as-git-worktree-manage/SKILL.md +0 -0
  20. data/handbook/workflow-instructions/git/worktree-create.wf.md +3 -3
  21. data/handbook/workflow-instructions/git/worktree-manage.wf.md +6 -6
  22. data/handbook/workflow-instructions/git/worktree.wf.md +2 -2
  23. data/lib/ace/git/worktree/atoms/duration_parser.rb +81 -0
  24. data/lib/ace/git/worktree/atoms/git_command.rb +0 -0
  25. data/lib/ace/git/worktree/atoms/path_expander.rb +0 -0
  26. data/lib/ace/git/worktree/atoms/relative_age_formatter.rb +59 -0
  27. data/lib/ace/git/worktree/atoms/slug_generator.rb +0 -0
  28. data/lib/ace/git/worktree/atoms/task_id_extractor.rb +20 -1
  29. data/lib/ace/git/worktree/atoms/text_truncator.rb +67 -0
  30. data/lib/ace/git/worktree/cli/commands/bootstrap.rb +0 -0
  31. data/lib/ace/git/worktree/cli/commands/cleanup.rb +0 -0
  32. data/lib/ace/git/worktree/cli/commands/config.rb +0 -0
  33. data/lib/ace/git/worktree/cli/commands/create.rb +0 -0
  34. data/lib/ace/git/worktree/cli/commands/list.rb +29 -8
  35. data/lib/ace/git/worktree/cli/commands/prune.rb +0 -0
  36. data/lib/ace/git/worktree/cli/commands/remove.rb +0 -0
  37. data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +0 -0
  38. data/lib/ace/git/worktree/cli/commands/switch.rb +0 -0
  39. data/lib/ace/git/worktree/cli.rb +1 -1
  40. data/lib/ace/git/worktree/commands/bootstrap_command.rb +0 -0
  41. data/lib/ace/git/worktree/commands/list_command.rb +90 -94
  42. data/lib/ace/git/worktree/commands/prune_command.rb +0 -0
  43. data/lib/ace/git/worktree/commands/remove_command.rb +0 -0
  44. data/lib/ace/git/worktree/commands/switch_command.rb +0 -0
  45. data/lib/ace/git/worktree/configuration.rb +0 -0
  46. data/lib/ace/git/worktree/models/task_index.rb +186 -0
  47. data/lib/ace/git/worktree/models/worktree_info.rb +52 -3
  48. data/lib/ace/git/worktree/models/worktree_metadata.rb +0 -0
  49. data/lib/ace/git/worktree/molecules/bootstrap_executor.rb +0 -0
  50. data/lib/ace/git/worktree/molecules/cleanup_applier.rb +0 -0
  51. data/lib/ace/git/worktree/molecules/config_loader.rb +0 -0
  52. data/lib/ace/git/worktree/molecules/current_task_linker.rb +0 -0
  53. data/lib/ace/git/worktree/molecules/hook_executor.rb +0 -0
  54. data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +0 -0
  55. data/lib/ace/git/worktree/molecules/pr_status_resolver.rb +172 -0
  56. data/lib/ace/git/worktree/molecules/recent_branch_counter.rb +70 -0
  57. data/lib/ace/git/worktree/molecules/task_association_resolver.rb +121 -0
  58. data/lib/ace/git/worktree/molecules/task_fetcher.rb +0 -0
  59. data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -0
  60. data/lib/ace/git/worktree/molecules/task_pusher.rb +0 -0
  61. data/lib/ace/git/worktree/molecules/task_status_updater.rb +0 -0
  62. data/lib/ace/git/worktree/molecules/toolchain_truster.rb +0 -0
  63. data/lib/ace/git/worktree/molecules/worktree_footer_formatter.rb +43 -0
  64. data/lib/ace/git/worktree/molecules/worktree_lister.rb +22 -93
  65. data/lib/ace/git/worktree/molecules/worktree_probe.rb +196 -0
  66. data/lib/ace/git/worktree/molecules/worktree_remover.rb +0 -0
  67. data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +241 -0
  68. data/lib/ace/git/worktree/organisms/worktree_list_report.rb +315 -0
  69. data/lib/ace/git/worktree/organisms/worktree_manager.rb +22 -20
  70. data/lib/ace/git/worktree/organisms/worktree_probe_runner.rb +320 -0
  71. data/lib/ace/git/worktree/version.rb +1 -1
  72. data/lib/ace/git/worktree.rb +14 -0
  73. metadata +15 -2
@@ -0,0 +1,186 @@
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
+ @canonical_directories = {}
40
+ @canonical_worktree_paths = {}
41
+ end
42
+
43
+ attr_reader :entries, :project_root
44
+
45
+ # Whether the project has a `.ace-tasks/` tree.
46
+ # @return [Boolean]
47
+ def available?
48
+ @available
49
+ end
50
+
51
+ # @return [Boolean]
52
+ def empty?
53
+ @entries.empty?
54
+ end
55
+
56
+ # Find an entry by short or full id.
57
+ #
58
+ # @param reference [String, nil]
59
+ # @return [Entry, nil]
60
+ def entry_for_reference(reference)
61
+ token = reference.to_s.strip
62
+ return nil if token.empty?
63
+
64
+ @entries.find { |entry| entry.full_id == token || entry.short_id == token }
65
+ end
66
+
67
+ # Whether the reference resolves to a known task.
68
+ #
69
+ # @param reference [String, nil]
70
+ # @return [Boolean]
71
+ def task?(reference)
72
+ !entry_for_reference(reference).nil?
73
+ end
74
+
75
+ # Resolve an arbitrary string to a short task id when it names a known
76
+ # task (extracting a task reference from it first).
77
+ #
78
+ # @param reference [String, nil]
79
+ # @return [String, nil]
80
+ def short_id_for(reference)
81
+ entry = entry_for_reference(reference)
82
+ return entry.short_id if entry
83
+
84
+ extracted = Atoms::TaskIDExtractor.normalize(reference)
85
+ extracted && task?(extracted) ? extracted : nil
86
+ end
87
+
88
+ # Find the task whose recorded worktree path matches.
89
+ #
90
+ # @param path [String, nil]
91
+ # @return [Entry, nil]
92
+ def find_by_worktree_path(path)
93
+ return nil if path.to_s.empty?
94
+
95
+ expanded = canonical_path(path)
96
+ @entries.find do |entry|
97
+ entry.worktree_path && canonical_worktree_path(entry) == expanded
98
+ end
99
+ end
100
+
101
+ # Find the task whose recorded worktree branch matches.
102
+ #
103
+ # @param branch [String, nil]
104
+ # @return [Entry, nil]
105
+ def find_by_worktree_branch(branch)
106
+ return nil if branch.to_s.empty?
107
+
108
+ @entries.find { |entry| entry.worktree_branch == branch }
109
+ end
110
+
111
+ # Find the task owning an absolute task directory.
112
+ #
113
+ # @param directory [String, nil]
114
+ # @return [Entry, nil]
115
+ def find_by_directory(directory)
116
+ return nil if directory.to_s.empty?
117
+
118
+ expanded = canonical_path(directory)
119
+ @entries.find { |entry| canonical_directory(entry) == expanded }
120
+ end
121
+
122
+ # Map changed file paths to task candidates.
123
+ #
124
+ # Paths are relative to the project root (as produced by git). Files
125
+ # changed under a task directory count towards that task; for a
126
+ # changed spec file the candidate id still comes from its directory.
127
+ #
128
+ # @param paths [Array<String>] repo-relative changed paths
129
+ # @return [Array<Hash>] `[{entry:, count:}]` sorted by count desc then
130
+ # short id asc
131
+ def candidates_for_paths(paths)
132
+ counts = Hash.new(0)
133
+ Array(paths).each do |relative_path|
134
+ next if relative_path.to_s.empty?
135
+
136
+ absolute = canonical_path(File.expand_path(relative_path, @project_root || Dir.pwd))
137
+ entry = find_by_enclosing_directory(absolute)
138
+ counts[entry] += 1 if entry
139
+ end
140
+
141
+ counts.map { |entry, count| {entry: entry, count: count} }
142
+ .sort_by { |candidate| [-candidate[:count], candidate[:entry].short_id] }
143
+ end
144
+
145
+ private
146
+
147
+ # Locate the indexed task directory containing a file path (any depth
148
+ # under the task directory, including nested spec/artifact folders).
149
+ def find_by_enclosing_directory(absolute_path)
150
+ @entries.find do |entry|
151
+ directory = canonical_directory(entry)
152
+ absolute_path == directory || absolute_path.start_with?("#{directory}/")
153
+ end
154
+ end
155
+
156
+ # Canonical forms are compared on every lookup; resolve each entry's
157
+ # directory / recorded worktree path exactly once (realpath is a
158
+ # syscall per path component and dominates lookup time otherwise).
159
+ def canonical_directory(entry)
160
+ @canonical_directories.fetch(entry) { @canonical_directories[entry] = canonical_path(entry.directory) }
161
+ end
162
+
163
+ def canonical_worktree_path(entry)
164
+ @canonical_worktree_paths.fetch(entry) do
165
+ @canonical_worktree_paths[entry] = entry.worktree_path ? canonical_path(entry.worktree_path) : nil
166
+ end
167
+ end
168
+
169
+ # Canonical comparison form for paths that may be reached through a
170
+ # symlinked root (e.g. macOS /var -> /private/var).
171
+ def canonical_path(value)
172
+ expanded = File.expand_path(value)
173
+ return File.realpath(expanded) if File.exist?(expanded)
174
+
175
+ parent = File.dirname(expanded)
176
+ return expanded if parent == expanded
177
+
178
+ File.join(canonical_path(parent), File.basename(expanded))
179
+ rescue StandardError
180
+ File.expand_path(value)
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -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
 
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -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
File without changes