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,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
|
-
|
|
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
|
|
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
|