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.
- checksums.yaml +4 -4
- data/.ace-defaults/git/worktree.yml +2 -2
- data/CHANGELOG.md +48 -0
- data/docs/getting-started.md +7 -4
- data/docs/usage.md +141 -24
- data/handbook/agents/worktree.ag.md +10 -6
- 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/relative_age_formatter.rb +59 -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/list.rb +29 -8
- data/lib/ace/git/worktree/cli.rb +1 -1
- data/lib/ace/git/worktree/commands/list_command.rb +90 -94
- 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/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_committer.rb +31 -31
- data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -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_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
|
@@ -15,9 +15,6 @@ module Ace
|
|
|
15
15
|
#
|
|
16
16
|
# @example Find worktree by task ID
|
|
17
17
|
# worktree = lister.find_by_task_id("081")
|
|
18
|
-
#
|
|
19
|
-
# @example List with task associations
|
|
20
|
-
# worktrees = lister.list_with_tasks
|
|
21
18
|
class WorktreeLister
|
|
22
19
|
# Fallback timeout for git commands
|
|
23
20
|
# Used only when config is unavailable
|
|
@@ -42,6 +39,18 @@ module Ace
|
|
|
42
39
|
|
|
43
40
|
public
|
|
44
41
|
|
|
42
|
+
# Read the worktree inventory.
|
|
43
|
+
#
|
|
44
|
+
# @return [Hash] `{success:, worktrees:}` or `{success: false, error:}`
|
|
45
|
+
def inventory
|
|
46
|
+
result = Atoms::GitCommand.worktree("list", "--porcelain", timeout: @timeout)
|
|
47
|
+
unless result[:success]
|
|
48
|
+
return {success: false, error: result[:error].to_s.empty? ? "git worktree list failed" : result[:error]}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{success: true, worktrees: Models::WorktreeInfo.from_git_output_list(result[:output])}
|
|
52
|
+
end
|
|
53
|
+
|
|
45
54
|
# List all worktrees in the repository
|
|
46
55
|
#
|
|
47
56
|
# @return [Array<WorktreeInfo>] Array of worktree information
|
|
@@ -51,22 +60,8 @@ module Ace
|
|
|
51
60
|
# worktrees = lister.list_all
|
|
52
61
|
# worktrees.each { |wt| puts "#{wt.branch} at #{wt.path}" }
|
|
53
62
|
def list_all
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Models::WorktreeInfo.from_git_output_list(output)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# List worktrees with task associations resolved
|
|
61
|
-
#
|
|
62
|
-
# @return [Array<WorktreeInfo>] Array of worktree information with task IDs
|
|
63
|
-
#
|
|
64
|
-
# @example
|
|
65
|
-
# worktrees = lister.list_with_tasks
|
|
66
|
-
# worktrees.each { |wt| puts "Task #{wt.task_id}: #{wt.branch}" if wt.task_associated? }
|
|
67
|
-
def list_with_tasks
|
|
68
|
-
worktrees = list_all
|
|
69
|
-
resolve_task_associations(worktrees)
|
|
63
|
+
result = inventory
|
|
64
|
+
result[:success] ? result[:worktrees] : []
|
|
70
65
|
end
|
|
71
66
|
|
|
72
67
|
# Find worktree by task ID
|
|
@@ -80,7 +75,7 @@ module Ace
|
|
|
80
75
|
# puts "Found worktree for task 081: #{worktree.path}"
|
|
81
76
|
# end
|
|
82
77
|
def find_by_task_id(task_id)
|
|
83
|
-
worktrees =
|
|
78
|
+
worktrees = list_all
|
|
84
79
|
Models::WorktreeInfo.find_by_task_id(worktrees, task_id.to_s)
|
|
85
80
|
end
|
|
86
81
|
|
|
@@ -191,7 +186,7 @@ module Ace
|
|
|
191
186
|
def search(query, search_in: [:branch, :path, :task_id])
|
|
192
187
|
return [] if query.nil? || query.empty?
|
|
193
188
|
|
|
194
|
-
worktrees =
|
|
189
|
+
worktrees = list_all
|
|
195
190
|
pattern = Regexp.new(query, Regexp::IGNORECASE)
|
|
196
191
|
|
|
197
192
|
worktrees.select do |worktree|
|
|
@@ -221,7 +216,7 @@ module Ace
|
|
|
221
216
|
# puts "Task-associated: #{stats[:task_associated]}"
|
|
222
217
|
# puts "Usable: #{stats[:usable]}"
|
|
223
218
|
def get_statistics(worktrees = nil)
|
|
224
|
-
worktrees = worktrees ? Array(worktrees) :
|
|
219
|
+
worktrees = worktrees ? Array(worktrees) : list_all
|
|
225
220
|
|
|
226
221
|
{
|
|
227
222
|
total: worktrees.length,
|
|
@@ -236,92 +231,26 @@ module Ace
|
|
|
236
231
|
}
|
|
237
232
|
end
|
|
238
233
|
|
|
239
|
-
# Format worktree list for
|
|
234
|
+
# Format worktree list for non-report consumers.
|
|
240
235
|
#
|
|
241
236
|
# @param worktrees [Array<WorktreeInfo>] Worktrees to format
|
|
242
|
-
# @param format [Symbol] Output format (:
|
|
237
|
+
# @param format [Symbol] Output format (:json, :simple)
|
|
243
238
|
# @return [String] Formatted output
|
|
244
239
|
#
|
|
245
240
|
# @example
|
|
246
|
-
# output = lister.format_for_display(worktrees, :
|
|
241
|
+
# output = lister.format_for_display(worktrees, :simple)
|
|
247
242
|
# puts output
|
|
248
|
-
def format_for_display(worktrees, format = :
|
|
243
|
+
def format_for_display(worktrees, format = :simple)
|
|
249
244
|
case format
|
|
250
|
-
when :table
|
|
251
|
-
format_as_table(worktrees)
|
|
252
245
|
when :json
|
|
253
246
|
format_as_json(worktrees)
|
|
254
|
-
when :simple
|
|
255
|
-
format_as_simple(worktrees)
|
|
256
247
|
else
|
|
257
|
-
|
|
248
|
+
format_as_simple(worktrees)
|
|
258
249
|
end
|
|
259
250
|
end
|
|
260
251
|
|
|
261
252
|
private
|
|
262
253
|
|
|
263
|
-
# Execute git worktree list command
|
|
264
|
-
#
|
|
265
|
-
# @return [String, nil] Command output or nil if failed
|
|
266
|
-
def execute_git_worktree_list
|
|
267
|
-
require_relative "../atoms/git_command"
|
|
268
|
-
result = Atoms::GitCommand.worktree("list", "--porcelain", timeout: @timeout)
|
|
269
|
-
result[:success] ? result[:output] : nil
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# Resolve task associations for worktrees
|
|
273
|
-
#
|
|
274
|
-
# @param worktrees [Array<WorktreeInfo>] Worktrees to process
|
|
275
|
-
# @return [Array<WorktreeInfo>] Worktrees with resolved task associations
|
|
276
|
-
def resolve_task_associations(worktrees)
|
|
277
|
-
return worktrees if worktrees.empty?
|
|
278
|
-
|
|
279
|
-
# Try to resolve task associations from worktree metadata
|
|
280
|
-
# This would typically involve looking up task files
|
|
281
|
-
# For now, we'll use the automatic extraction from WorktreeInfo
|
|
282
|
-
|
|
283
|
-
worktrees
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
# Format worktrees as a table
|
|
287
|
-
#
|
|
288
|
-
# @param worktrees [Array<WorktreeInfo>] Worktrees to format
|
|
289
|
-
# @return [String] Table-formatted string
|
|
290
|
-
def format_as_table(worktrees)
|
|
291
|
-
return "No worktrees found.\n" if worktrees.empty?
|
|
292
|
-
|
|
293
|
-
# Calculate column widths
|
|
294
|
-
max_path_width = [worktrees.map { |wt| wt.path.length }.max, 40].max
|
|
295
|
-
max_branch_width = [worktrees.map { |wt| (wt.branch || "detached").length }.max, 15].max
|
|
296
|
-
max_task_width = [worktrees.map { |wt| (wt.task_id || "-").length }.max, 8].max
|
|
297
|
-
|
|
298
|
-
# Build header
|
|
299
|
-
header = sprintf("%-#{max_task_width}s %-#{max_branch_width}s %-#{max_path_width}s %s",
|
|
300
|
-
"Task", "Branch", "Path", "Status")
|
|
301
|
-
separator = "-" * header.length
|
|
302
|
-
|
|
303
|
-
# Build table rows
|
|
304
|
-
rows = worktrees.map do |wt|
|
|
305
|
-
status = if wt.bare
|
|
306
|
-
"bare"
|
|
307
|
-
elsif wt.detached
|
|
308
|
-
"detached"
|
|
309
|
-
elsif wt.task_associated?
|
|
310
|
-
"task"
|
|
311
|
-
else
|
|
312
|
-
"normal"
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
sprintf("%-#{max_task_width}s %-#{max_branch_width}s %-#{max_path_width}s %s",
|
|
316
|
-
wt.task_id || "-",
|
|
317
|
-
wt.branch || "detached",
|
|
318
|
-
wt.path,
|
|
319
|
-
status)
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
[header, separator, *rows].join("\n") + "\n"
|
|
323
|
-
end
|
|
324
|
-
|
|
325
254
|
# Format worktrees as JSON
|
|
326
255
|
#
|
|
327
256
|
# @param worktrees [Array<WorktreeInfo>] Worktrees to format
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Git
|
|
8
|
+
module Worktree
|
|
9
|
+
module Molecules
|
|
10
|
+
# Read-only inspection of one worktree, designed to run inside a forked
|
|
11
|
+
# probe child (see {Organisms::WorktreeProbeRunner}).
|
|
12
|
+
#
|
|
13
|
+
# The probe reports raw signals only: HEAD commit metadata, uncommitted
|
|
14
|
+
# change counts, task files changed in the worktree, and the resolved
|
|
15
|
+
# `_current` symlink target. The parent process owns clock reads,
|
|
16
|
+
# rendering, and task/PR resolution so ordering and ages stay
|
|
17
|
+
# deterministic.
|
|
18
|
+
#
|
|
19
|
+
# All git calls pass the worktree explicitly via `-C <path>` (ADR-031);
|
|
20
|
+
# the probe never changes the parent process working directory and
|
|
21
|
+
# never writes repository state.
|
|
22
|
+
class WorktreeProbe
|
|
23
|
+
# Marker for a task-tree-relative path prefix.
|
|
24
|
+
TASK_TREE_PREFIX = ".ace-tasks/"
|
|
25
|
+
|
|
26
|
+
# @param project_root [String] primary project root (contains `.ace-tasks/`)
|
|
27
|
+
# @param base_ref [String, nil] primary worktree HEAD sha used as the
|
|
28
|
+
# merge-base reference for committed task-file diffs
|
|
29
|
+
def initialize(project_root:, base_ref: nil)
|
|
30
|
+
@project_root = project_root
|
|
31
|
+
@base_ref = base_ref
|
|
32
|
+
@task_root = project_root ? File.join(project_root, ".ace-tasks") : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Probe one worktree.
|
|
36
|
+
#
|
|
37
|
+
# @param path [String] worktree directory
|
|
38
|
+
# @return [Hash, nil] serializable probe payload, or nil when the
|
|
39
|
+
# worktree cannot be inspected (missing path / git failure)
|
|
40
|
+
def probe(path)
|
|
41
|
+
return nil unless path && File.directory?(path)
|
|
42
|
+
|
|
43
|
+
head = head_metadata(path)
|
|
44
|
+
status = status_snapshot(path)
|
|
45
|
+
return nil if status.nil?
|
|
46
|
+
|
|
47
|
+
committed_task_files = committed_task_paths(path, head && head[:sha])
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
head_sha: head && head[:sha],
|
|
51
|
+
head_committer_epoch: head && head[:committer_epoch],
|
|
52
|
+
head_subject: head && head[:subject],
|
|
53
|
+
staged: status[:staged],
|
|
54
|
+
unstaged: status[:unstaged],
|
|
55
|
+
untracked: status[:untracked],
|
|
56
|
+
uncommitted_paths: status[:paths],
|
|
57
|
+
changed_task_files: status[:paths].select { |p| task_tree_path?(p) },
|
|
58
|
+
committed_task_files: committed_task_files,
|
|
59
|
+
symlink_target: current_task_target(path)
|
|
60
|
+
}
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Resolve the `_current` symlink at a worktree root when it points
|
|
66
|
+
# into the project task tree.
|
|
67
|
+
#
|
|
68
|
+
# @param path [String] worktree directory
|
|
69
|
+
# @return [String, nil] absolute task directory
|
|
70
|
+
def current_task_target(path)
|
|
71
|
+
symlink = File.join(path, "_current")
|
|
72
|
+
return nil unless File.symlink?(symlink) && @task_root
|
|
73
|
+
|
|
74
|
+
target = canonical_path(File.expand_path(File.readlink(symlink), path))
|
|
75
|
+
root = canonical_path(@task_root)
|
|
76
|
+
target.start_with?("#{root}/") ? target : nil
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Canonical comparison form for paths that may be reached through a
|
|
82
|
+
# symlinked root (e.g. macOS /var -> /private/var).
|
|
83
|
+
def canonical_path(value)
|
|
84
|
+
File.realpath(value)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
File.expand_path(value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# @return [Hash, nil] `{sha:, committer_epoch:, subject:}` for HEAD
|
|
92
|
+
def head_metadata(path)
|
|
93
|
+
output = git(path, "log", "-1", "--format=%H%x1f%ct%x1f%s")
|
|
94
|
+
return nil if output.nil?
|
|
95
|
+
|
|
96
|
+
sha, epoch, subject = output.chomp.split("\x1f", 3)
|
|
97
|
+
return nil if sha.to_s.empty?
|
|
98
|
+
|
|
99
|
+
{sha: sha, committer_epoch: Integer(epoch, 10), subject: subject.to_s}
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Hash, nil] staged/unstaged/untracked counts and changed paths
|
|
105
|
+
def status_snapshot(path)
|
|
106
|
+
output = git(path, "status", "--porcelain=v1", "-z", "--untracked-files=all")
|
|
107
|
+
return nil if output.nil?
|
|
108
|
+
|
|
109
|
+
staged = 0
|
|
110
|
+
unstaged = 0
|
|
111
|
+
untracked = 0
|
|
112
|
+
paths = []
|
|
113
|
+
records = output.split("\x00")
|
|
114
|
+
index = 0
|
|
115
|
+
|
|
116
|
+
while index < records.length
|
|
117
|
+
record = records[index]
|
|
118
|
+
index += 1
|
|
119
|
+
next if record.nil? || record.length < 4
|
|
120
|
+
|
|
121
|
+
xy = record[0, 2]
|
|
122
|
+
file = scrub(record[3..].to_s)
|
|
123
|
+
# Rename/copy records carry the original path in the next record.
|
|
124
|
+
index += 1 if xy[0] == "R" || xy[0] == "C"
|
|
125
|
+
|
|
126
|
+
if xy == "??"
|
|
127
|
+
untracked += 1
|
|
128
|
+
else
|
|
129
|
+
staged += 1 unless xy[0] == " " || xy[0] == "?"
|
|
130
|
+
unstaged += 1 unless xy[1] == " " || xy[1] == "?"
|
|
131
|
+
end
|
|
132
|
+
paths << file
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
{staged: staged, unstaged: unstaged, untracked: untracked, paths: paths}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Task-tree files changed by this worktree's commits, relative to the
|
|
139
|
+
# merge-base with the primary worktree. Falls back to the HEAD
|
|
140
|
+
# commit's changed files when no merge-base exists.
|
|
141
|
+
#
|
|
142
|
+
# @return [Array<String>] repo-relative `.ace-tasks/` paths
|
|
143
|
+
def committed_task_paths(path, head_sha)
|
|
144
|
+
return [] if head_sha.nil?
|
|
145
|
+
|
|
146
|
+
merge_base = @base_ref ? git(path, "merge-base", @base_ref, head_sha)&.strip : nil
|
|
147
|
+
output = if merge_base && !merge_base.empty?
|
|
148
|
+
git(path, "diff", "--name-only", merge_base, head_sha, "--", ".ace-tasks")
|
|
149
|
+
else
|
|
150
|
+
git(path, "diff-tree", "--no-commit-id", "--name-only", "-r", head_sha, "--", ".ace-tasks")
|
|
151
|
+
end
|
|
152
|
+
return [] if output.nil?
|
|
153
|
+
|
|
154
|
+
output.lines.map { |line| scrub(line.chomp) }.reject(&:empty?).select { |p| task_tree_path?(p) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def task_tree_path?(relative_path)
|
|
158
|
+
relative_path.start_with?(TASK_TREE_PREFIX)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Run git in the worktree; nil on any failure.
|
|
162
|
+
#
|
|
163
|
+
# @return [String, nil]
|
|
164
|
+
def git(path, *args)
|
|
165
|
+
stdout, _stderr, status = Open3.capture3("git", "-C", path, *args)
|
|
166
|
+
status.success? ? stdout : nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def scrub(value)
|
|
170
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require_relative "../atoms/relative_age_formatter"
|
|
5
|
+
require_relative "pr_status_resolver"
|
|
6
|
+
require_relative "../atoms/text_truncator"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Git
|
|
10
|
+
module Worktree
|
|
11
|
+
module Molecules
|
|
12
|
+
# Renders the width-aware listing table.
|
|
13
|
+
#
|
|
14
|
+
# Layout targets (two-space gutters): Task = max(task cells, 4),
|
|
15
|
+
# Branch 25 (min 12), Age 5, Commit 24 (min 10), Changes 7, PR 11,
|
|
16
|
+
# Path 32 (min 10). When the target total exceeds the detected width,
|
|
17
|
+
# columns shrink in priority order Path -> Commit -> Branch down to
|
|
18
|
+
# their minima, then Path is dropped, then Commit. Compact output never
|
|
19
|
+
# exceeds the detected width; `--full` prints untruncated absolute paths
|
|
20
|
+
# and is exempt from width limits.
|
|
21
|
+
class WorktreeTableFormatter
|
|
22
|
+
GUTTER = " "
|
|
23
|
+
COLUMNS_MIN_WIDTH = 40
|
|
24
|
+
DEFAULT_WIDTH = 120
|
|
25
|
+
|
|
26
|
+
BRANCH_TARGET = 25
|
|
27
|
+
BRANCH_MIN = 12
|
|
28
|
+
AGE_WIDTH = 5
|
|
29
|
+
COMMIT_TARGET = 24
|
|
30
|
+
COMMIT_MIN = 10
|
|
31
|
+
CHANGES_WIDTH = 7
|
|
32
|
+
PR_WIDTH = 11
|
|
33
|
+
PATH_TARGET = 32
|
|
34
|
+
PATH_MIN = 10
|
|
35
|
+
TASK_MIN = 4
|
|
36
|
+
|
|
37
|
+
# @param width [Integer] detected terminal width
|
|
38
|
+
# @param full [Boolean] exhaustive listing (no truncation)
|
|
39
|
+
# @param env [Hash] environment (HOME shortening, COLUMNS detection)
|
|
40
|
+
def initialize(width: nil, full: false, env: ENV, stdout: $stdout)
|
|
41
|
+
@width = width || self.class.detected_width(env: env, stdout: stdout)
|
|
42
|
+
@full = full
|
|
43
|
+
@env = env
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Detect the output width.
|
|
47
|
+
#
|
|
48
|
+
# @param env [Hash]
|
|
49
|
+
# @param stdout [IO]
|
|
50
|
+
# @return [Integer]
|
|
51
|
+
def self.detected_width(env: ENV, stdout: $stdout)
|
|
52
|
+
columns = env["COLUMNS"].to_s.strip
|
|
53
|
+
if columns.match?(/\A\d+\z/)
|
|
54
|
+
value = Integer(columns, 10)
|
|
55
|
+
return value if value >= COLUMNS_MIN_WIDTH
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if stdout.respond_to?(:tty?) && stdout.tty?
|
|
59
|
+
winsize = IO.console&.winsize
|
|
60
|
+
return winsize.last if winsize&.last
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
DEFAULT_WIDTH
|
|
64
|
+
rescue StandardError
|
|
65
|
+
DEFAULT_WIDTH
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Render header + separator + rows.
|
|
69
|
+
#
|
|
70
|
+
# @param worktrees [Array<Models::WorktreeInfo>]
|
|
71
|
+
# @return [Array<String>] output lines
|
|
72
|
+
def render(worktrees)
|
|
73
|
+
rows = Array(worktrees)
|
|
74
|
+
layout = compute_layout(rows)
|
|
75
|
+
[header_line(layout), separator_line(layout), *rows.map { |worktree| row_line(worktree, layout) }]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
Layout = Struct.new(:task, :branch, :age, :commit, :changes, :pr, :path, :total_width,
|
|
81
|
+
keyword_init: true)
|
|
82
|
+
|
|
83
|
+
def compute_layout(worktrees)
|
|
84
|
+
task_width = [TASK_MIN, *worktrees.map { |wt| task_cell(wt).length }].max
|
|
85
|
+
branch_width = BRANCH_TARGET
|
|
86
|
+
commit_width = COMMIT_TARGET
|
|
87
|
+
path_width = PATH_TARGET
|
|
88
|
+
|
|
89
|
+
if @full
|
|
90
|
+
path_width = [PATH_TARGET, *worktrees.map { |wt| full_path(wt).length }].max
|
|
91
|
+
return build_layout(task_width, branch_width, commit_width, path_width)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
while total_width(task_width, branch_width, commit_width, path_width) > @width
|
|
95
|
+
if path_width > PATH_MIN
|
|
96
|
+
path_width -= 1
|
|
97
|
+
elsif commit_width > COMMIT_MIN
|
|
98
|
+
commit_width -= 1
|
|
99
|
+
elsif branch_width > BRANCH_MIN
|
|
100
|
+
branch_width -= 1
|
|
101
|
+
else
|
|
102
|
+
break
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
layout = build_layout(task_width, branch_width, commit_width, path_width)
|
|
107
|
+
return layout if layout[:total_width] <= @width
|
|
108
|
+
|
|
109
|
+
drop_path(layout)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_layout(task_width, branch_width, commit_width, path_width)
|
|
113
|
+
total = total_width(task_width, branch_width, commit_width, path_width)
|
|
114
|
+
Layout.new(task: task_width, branch: branch_width, age: AGE_WIDTH, commit: commit_width,
|
|
115
|
+
changes: CHANGES_WIDTH, pr: PR_WIDTH, path: path_width, total_width: total)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def total_width(task_width, branch_width, commit_width, path_width)
|
|
119
|
+
task_width + branch_width + AGE_WIDTH + commit_width + CHANGES_WIDTH + PR_WIDTH +
|
|
120
|
+
path_width + (GUTTER.length * 6)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Drop the Path column when even the minimum layout overflows; then
|
|
124
|
+
# drop Commit as the last resort.
|
|
125
|
+
def drop_path(layout)
|
|
126
|
+
without_path = Layout.new(
|
|
127
|
+
task: layout[:task], branch: layout[:branch], age: layout[:age], commit: layout[:commit],
|
|
128
|
+
changes: layout[:changes], pr: layout[:pr], path: 0,
|
|
129
|
+
total_width: layout[:total_width] - layout[:path] - GUTTER.length
|
|
130
|
+
)
|
|
131
|
+
return without_path if without_path[:total_width] <= @width
|
|
132
|
+
|
|
133
|
+
Layout.new(
|
|
134
|
+
task: without_path[:task], branch: without_path[:branch], age: without_path[:age],
|
|
135
|
+
commit: 0, changes: without_path[:changes], pr: without_path[:pr], path: 0,
|
|
136
|
+
total_width: without_path[:total_width] - without_path[:commit] - GUTTER.length
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def header_line(layout)
|
|
141
|
+
cells = []
|
|
142
|
+
cells << pad("Task", layout[:task], :left)
|
|
143
|
+
cells << pad("Branch", layout[:branch], :left)
|
|
144
|
+
cells << pad("Age", layout[:age], :right)
|
|
145
|
+
cells << pad("Commit", layout[:commit], :left) if layout[:commit].positive?
|
|
146
|
+
cells << pad("Changes", layout[:changes], :right)
|
|
147
|
+
cells << pad("PR", layout[:pr], :left)
|
|
148
|
+
cells << pad("Path", layout[:path], :left) if layout[:path].positive?
|
|
149
|
+
cells.join(GUTTER).rstrip
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def separator_line(layout)
|
|
153
|
+
"-" * layout[:total_width]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def row_line(worktree, layout)
|
|
157
|
+
cells = []
|
|
158
|
+
cells << pad(task_cell(worktree), layout[:task], :left)
|
|
159
|
+
cells << pad(branch_cell(worktree, layout[:branch]), layout[:branch], :left)
|
|
160
|
+
cells << pad(age_cell(worktree), layout[:age], :right)
|
|
161
|
+
cells << pad(commit_cell(worktree, layout[:commit]), layout[:commit], :left) if layout[:commit].positive?
|
|
162
|
+
cells << pad(changes_cell(worktree), layout[:changes], :right)
|
|
163
|
+
cells << pad(pr_cell(worktree), layout[:pr], :left)
|
|
164
|
+
cells << pad(path_cell(worktree, layout[:path]), layout[:path], :left) if layout[:path].positive?
|
|
165
|
+
cells.join(GUTTER).rstrip
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --- Cell contents ---
|
|
169
|
+
|
|
170
|
+
def task_cell(worktree)
|
|
171
|
+
return "-" if worktree.task_id.to_s.empty?
|
|
172
|
+
|
|
173
|
+
extra = worktree.task_ids.length - 1
|
|
174
|
+
extra.positive? ? "#{worktree.task_id}+#{extra}" : worktree.task_id
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def branch_cell(worktree, width)
|
|
178
|
+
label = worktree.branch.to_s.empty? ? "detached" : worktree.branch
|
|
179
|
+
Atoms::TextTruncator.middle(label, width)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def age_cell(worktree)
|
|
183
|
+
return "?" if worktree.probe_failed?
|
|
184
|
+
return "never" if worktree.last_commit.nil?
|
|
185
|
+
|
|
186
|
+
Atoms::RelativeAgeFormatter.format(worktree.last_commit[:age_hours].to_f * 3600)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def commit_cell(worktree, width)
|
|
190
|
+
return "?" if worktree.probe_failed?
|
|
191
|
+
return "" if worktree.last_commit.nil?
|
|
192
|
+
|
|
193
|
+
Atoms::TextTruncator.right(worktree.last_commit[:subject].to_s, width)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def changes_cell(worktree)
|
|
197
|
+
return "?" if worktree.probe_failed?
|
|
198
|
+
return "-" if worktree.uncommitted.nil?
|
|
199
|
+
|
|
200
|
+
worktree.uncommitted[:total].to_i.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def pr_cell(worktree)
|
|
204
|
+
PrStatusResolver.cell(worktree.pr)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def path_cell(worktree, width)
|
|
208
|
+
return "" if width <= 0
|
|
209
|
+
|
|
210
|
+
path = @full ? full_path(worktree) : compact_path(worktree)
|
|
211
|
+
Atoms::TextTruncator.left(path, width)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def full_path(worktree)
|
|
215
|
+
File.expand_path(worktree.path)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def compact_path(worktree)
|
|
219
|
+
path = worktree.path.to_s
|
|
220
|
+
home = @env["HOME"].to_s
|
|
221
|
+
path = "~#{path[home.length..]}" if !home.empty? && path.start_with?(home)
|
|
222
|
+
path
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def pad(value, width, alignment)
|
|
226
|
+
text = value.to_s
|
|
227
|
+
text = text[0, width].to_s if text.length > width
|
|
228
|
+
return text.ljust(width) if alignment == :left
|
|
229
|
+
|
|
230
|
+
text.rjust(width)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|