ace-git-worktree 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/git/worktree.yml +2 -2
  3. data/CHANGELOG.md +48 -0
  4. data/docs/getting-started.md +7 -4
  5. data/docs/usage.md +141 -24
  6. data/handbook/agents/worktree.ag.md +10 -6
  7. data/handbook/workflow-instructions/git/worktree-create.wf.md +3 -3
  8. data/handbook/workflow-instructions/git/worktree-manage.wf.md +6 -6
  9. data/handbook/workflow-instructions/git/worktree.wf.md +2 -2
  10. data/lib/ace/git/worktree/atoms/duration_parser.rb +81 -0
  11. data/lib/ace/git/worktree/atoms/relative_age_formatter.rb +59 -0
  12. data/lib/ace/git/worktree/atoms/task_id_extractor.rb +20 -1
  13. data/lib/ace/git/worktree/atoms/text_truncator.rb +67 -0
  14. data/lib/ace/git/worktree/cli/commands/list.rb +29 -8
  15. data/lib/ace/git/worktree/cli.rb +1 -1
  16. data/lib/ace/git/worktree/commands/list_command.rb +90 -94
  17. data/lib/ace/git/worktree/models/task_index.rb +171 -0
  18. data/lib/ace/git/worktree/models/worktree_info.rb +52 -3
  19. data/lib/ace/git/worktree/molecules/pr_status_resolver.rb +172 -0
  20. data/lib/ace/git/worktree/molecules/recent_branch_counter.rb +70 -0
  21. data/lib/ace/git/worktree/molecules/task_association_resolver.rb +121 -0
  22. data/lib/ace/git/worktree/molecules/task_committer.rb +31 -31
  23. data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -0
  24. data/lib/ace/git/worktree/molecules/worktree_footer_formatter.rb +43 -0
  25. data/lib/ace/git/worktree/molecules/worktree_lister.rb +22 -93
  26. data/lib/ace/git/worktree/molecules/worktree_probe.rb +176 -0
  27. data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +236 -0
  28. data/lib/ace/git/worktree/organisms/worktree_list_report.rb +277 -0
  29. data/lib/ace/git/worktree/organisms/worktree_manager.rb +22 -20
  30. data/lib/ace/git/worktree/organisms/worktree_probe_runner.rb +320 -0
  31. data/lib/ace/git/worktree/version.rb +1 -1
  32. data/lib/ace/git/worktree.rb +14 -0
  33. metadata +15 -2
@@ -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
- output = execute_git_worktree_list
55
- return [] unless output
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 = list_with_tasks
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 = list_with_tasks
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) : list_with_tasks
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 display
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 (:table, :json, :simple)
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, :table)
241
+ # output = lister.format_for_display(worktrees, :simple)
247
242
  # puts output
248
- def format_for_display(worktrees, format = :table)
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
- format_as_table(worktrees)
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