ace-git-worktree 0.26.1 → 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.
- 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 +41 -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 +141 -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 +171 -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 +176 -0
- data/lib/ace/git/worktree/molecules/worktree_remover.rb +0 -0
- data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +236 -0
- data/lib/ace/git/worktree/organisms/worktree_list_report.rb +277 -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
|
@@ -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
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "date"
|
|
5
|
+
require "open3"
|
|
6
|
+
require_relative "../atoms/task_id_extractor"
|
|
7
|
+
require_relative "../models/task_index"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Git
|
|
11
|
+
module Worktree
|
|
12
|
+
module Molecules
|
|
13
|
+
# Builds the `.ace-tasks/` task index once per listing run.
|
|
14
|
+
#
|
|
15
|
+
# Scans every task spec (`*.s.md`) under the project's task tree,
|
|
16
|
+
# including special folders such as `_archive/` and `_maybe/`, and reads
|
|
17
|
+
# only the frontmatter (``id`` plus recorded ``worktree`` metadata).
|
|
18
|
+
# Current-tree entries win over archived entries for the same short id.
|
|
19
|
+
class TaskIndexBuilder
|
|
20
|
+
# File glob for task specs under the task root.
|
|
21
|
+
SPEC_GLOB = "**/*.s.md"
|
|
22
|
+
|
|
23
|
+
# @param project_root [String, nil] project root (defaults to the git
|
|
24
|
+
# common-dir parent of the current repository)
|
|
25
|
+
def initialize(project_root: nil)
|
|
26
|
+
@project_root = project_root
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Models::TaskIndex]
|
|
30
|
+
def build
|
|
31
|
+
root = resolved_project_root
|
|
32
|
+
tasks_root = root ? File.join(root, ".ace-tasks") : nil
|
|
33
|
+
unless tasks_root && Dir.exist?(tasks_root)
|
|
34
|
+
return Models::TaskIndex.new(available: false, project_root: root)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
entries = Dir.glob(File.join(tasks_root, SPEC_GLOB)).filter_map do |spec_path|
|
|
38
|
+
entry_for(spec_path, root)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Models::TaskIndex.new(entries: deduplicate(entries), available: true, project_root: root)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Resolve the project root: the primary working tree that holds
|
|
47
|
+
# `.ace-tasks/`. Uses the git common dir so a linked worktree still
|
|
48
|
+
# reads the project's task tree.
|
|
49
|
+
def resolved_project_root
|
|
50
|
+
return File.expand_path(@project_root) if @project_root
|
|
51
|
+
|
|
52
|
+
out, status = Open3.capture2("git", "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
53
|
+
return Dir.pwd unless status.success?
|
|
54
|
+
|
|
55
|
+
common_dir = out.to_s.strip
|
|
56
|
+
return Dir.pwd if common_dir.empty?
|
|
57
|
+
|
|
58
|
+
File.dirname(common_dir)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
Dir.pwd
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Models::TaskIndex::Entry, nil]
|
|
64
|
+
def entry_for(spec_path, root)
|
|
65
|
+
frontmatter = read_frontmatter(spec_path)
|
|
66
|
+
return nil if frontmatter.nil?
|
|
67
|
+
|
|
68
|
+
full_id = frontmatter["id"].to_s.strip
|
|
69
|
+
short_id = Atoms::TaskIDExtractor.short_id(full_id) ||
|
|
70
|
+
Atoms::TaskIDExtractor.normalize(full_id)
|
|
71
|
+
return nil if short_id.nil? || short_id.empty? || full_id.empty?
|
|
72
|
+
|
|
73
|
+
worktree = frontmatter["worktree"].is_a?(Hash) ? frontmatter["worktree"] : {}
|
|
74
|
+
Models::TaskIndex::Entry.new(
|
|
75
|
+
full_id: full_id,
|
|
76
|
+
short_id: short_id,
|
|
77
|
+
directory: File.dirname(spec_path),
|
|
78
|
+
spec_path: spec_path,
|
|
79
|
+
worktree_path: expand_worktree_path(worktree["path"], root),
|
|
80
|
+
worktree_branch: blank_to_nil(worktree["branch"])
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Hash, nil] parsed YAML frontmatter
|
|
85
|
+
def read_frontmatter(spec_path)
|
|
86
|
+
content = File.read(spec_path)
|
|
87
|
+
match = content.match(/\A---\s*\n(.*?)\n---\s*(?:\n|\z)/m)
|
|
88
|
+
return nil unless match
|
|
89
|
+
|
|
90
|
+
YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: true) || {}
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def expand_worktree_path(path, root)
|
|
96
|
+
return nil if path.to_s.strip.empty?
|
|
97
|
+
|
|
98
|
+
File.expand_path(path.to_s.strip, root)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def blank_to_nil(value)
|
|
102
|
+
value.to_s.strip.empty? ? nil : value.to_s.strip
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Keep one entry per short id: the current tree wins over special
|
|
106
|
+
# folders (first path segment starting with "_").
|
|
107
|
+
def deduplicate(entries)
|
|
108
|
+
entries.group_by(&:short_id).map do |_short_id, candidates|
|
|
109
|
+
candidates.min_by { |entry| [archived?(entry) ? 1 : 0, entry.directory] }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def archived?(entry)
|
|
114
|
+
relative = entry.directory.sub(%r{\A.*/\.ace-tasks/}, "")
|
|
115
|
+
relative.split("/").any? { |segment| segment.start_with?("_") }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Worktree
|
|
6
|
+
module Molecules
|
|
7
|
+
# Renders the listing header, empty-state line, and two-line count footer.
|
|
8
|
+
class WorktreeFooterFormatter
|
|
9
|
+
# @param window_label [String] canonical activity window (e.g. "18h")
|
|
10
|
+
# @param full [Boolean] whether every worktree is listed
|
|
11
|
+
def initialize(window_label:, full: false)
|
|
12
|
+
@window_label = window_label
|
|
13
|
+
@full = full
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [String] e.g. "Active worktrees (last 18h) -- 3 of 88:"
|
|
17
|
+
def header(active_count:, total_count:)
|
|
18
|
+
"Active worktrees (last #{@window_label}) -- #{active_count} of #{total_count}:"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String] empty-state line for a zero-active window
|
|
22
|
+
def no_active_message
|
|
23
|
+
"No active worktrees in the last #{@window_label}."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Array<String>] the count footer lines
|
|
27
|
+
def lines(inactive:, task_associated:, usable:, recent_branches:)
|
|
28
|
+
inactive_line = if @full
|
|
29
|
+
"Inactive: #{inactive}"
|
|
30
|
+
else
|
|
31
|
+
"Inactive: #{inactive} (use --full to list all)"
|
|
32
|
+
end
|
|
33
|
+
[
|
|
34
|
+
inactive_line,
|
|
35
|
+
"Task-associated: #{task_associated} Usable: #{usable} " \
|
|
36
|
+
"Recent branches without worktrees: #{recent_branches}"
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|