ace-git-worktree 0.27.0 → 0.27.3

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.
data/README.md CHANGED
@@ -14,6 +14,10 @@
14
14
 
15
15
  > Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
16
16
 
17
+ > **State owner:** `.ace-wt/` -- one worktree per project task, owning only the
18
+ > Git checkout and branch. Task metadata stays in `.ace-tasks/`; assignment
19
+ > plans stay in `.ace-local/assign/`. See [docs/lab-flow.md](../docs/lab-flow.md).
20
+
17
21
  [Getting Started](docs/getting-started.md) | [Usage Guide](docs/usage.md) | [Handbook - Skills, Agents, Templates](docs/handbook.md)
18
22
 
19
23
  ![ace-git-worktree demo](docs/demo/ace-git-worktree-getting-started.gif)
@@ -36,6 +40,9 @@
36
40
 
37
41
  **Orchestrate parallel task work with ace-overseer** - pair with [ace-overseer](../ace-overseer) to spin up worktrees per task, drive agents in parallel, and prune finished worktrees automatically.
38
42
 
43
+ > **Planned (L6):** the private overseer queue that hands one worktree between
44
+ > worker, reviewer, and integrator -- not available yet.
45
+
39
46
  ## Testing Contract
40
47
 
41
48
  Run package tests with the restarted deterministic + scenario split:
data/docs/usage.md CHANGED
@@ -74,6 +74,11 @@ Created task worktree and branch for 081
74
74
 
75
75
  **Next steps:** Run `cd "$(ace-git-worktree switch 081)"` to move into the new worktree.
76
76
 
77
+ Worktree creation is role- and folder-name-agnostic: it does not inspect
78
+ `.gitignore` and does not classify management tasks. If a requested worktree
79
+ does not contain the named task, the executor reports the missing task;
80
+ preparing a correct delegation is the requester's responsibility.
81
+
77
82
  ### Scenario 2: Review a Pull Request in Its Own Worktree
78
83
 
79
84
  **Goal:** Create a dedicated workspace for PR review or patching, on any
@@ -292,21 +297,29 @@ association is always inferred.
292
297
 
293
298
  ```text
294
299
  Active worktrees (last 18h) -- 3 of 88:
295
- Task Branch Age Commit Changes PR Path
296
- ----------------------------------------------------------------------------------------------
297
- k9y codex/r3-bounded-convergence 17h feat(review): define bo… 2 #142 draft …/codex-r3-bounded-convergence
298
- - codex/ei9-campaign-join 8m feat(ace-assign): compl… - - …/ei9-campaign-composition-join
300
+ Task Branch Age Commit Changes PR
301
+ ---------------------------------------------------------------------------------------
302
+ k9y codex/r3-bounded-convergence 17h feat(review): define bo… 2 #142 draft
303
+ ~/Ps/ace/.ace-wt/codex-r3-bounded-convergence
304
+
305
+ - codex/ei9-campaign-join 8m feat(ace-assign): compl… - -
306
+ ~/Ps/ace/.ace-wt/ei9-campaign-composition-join
299
307
 
300
308
  Inactive: 85 (use --full to list all)
301
309
  Task-associated: 14 Usable: 71 Recent branches without worktrees: 4
302
310
  ```
303
311
 
312
+ Each worktree renders as three lines: the aligned columns, the
313
+ never-truncated path (indented to the `Commit` column, `$HOME` shortened to
314
+ `~` in compact mode, absolute with `--full`), and a blank separator line, so
315
+ every displayed path is directly usable (`cd`, copy/paste).
316
+
304
317
  **Activity:** a worktree is active when the last commit reachable from its
305
- checked-out HEAD is inside the window **or** it has uncommitted changes (staged,
306
- unstaged, or untracked). Bare worktrees are never active. Filters apply before
307
- the active/inactive split, so the header and footer counts always describe the
308
- filtered set. `Recent branches without worktrees` is branch-based and is not
309
- affected by worktree filters.
318
+ checked-out HEAD is inside the window **or** it has uncommitted changes
319
+ (staged, unstaged, or untracked under `.ace-tasks/`). Bare worktrees are never
320
+ active. Filters apply before the active/inactive split, so the header and
321
+ footer counts always describe the filtered set. `Recent branches without
322
+ worktrees` is branch-based and is not affected by worktree filters.
310
323
 
311
324
  **Columns:**
312
325
 
@@ -316,11 +329,11 @@ affected by worktree filters.
316
329
  * `Age` - last commit age in one unit (`8m`, `3h`, `9d`, `3w`, `2mo`, `1y`);
317
330
  `never` when the branch has no commits; future timestamps render `0m`.
318
331
  * `Commit` - last commit subject, right-truncated with `…`.
319
- * `Changes` - uncommitted file count (staged + unstaged + untracked); `-` when
320
- clean.
332
+ * `Changes` - uncommitted file count (staged + unstaged + untracked files
333
+ under `.ace-tasks/`); `-` when clean. Untracked files outside the task tree
334
+ are not scanned: the deep untracked walk dominates listing time on large
335
+ repositories, and stray artifacts are not an activity signal.
321
336
  * `PR` - `#N` (open), `#N draft`, `#N merged`, `#N closed`, or `-`.
322
- * `Path` - `$HOME` shortened to `~` and left-elided in compact mode; absolute
323
- and untruncated with `--full`.
324
337
 
325
338
  **Task association (name-independent), in precedence order:**
326
339
 
@@ -335,15 +348,19 @@ affected by worktree filters.
335
348
  name, then by HEAD commit sha, then by a unique task reference in the PR
336
349
  title/body. Unconfigured/offline forges render `-` and never fail the command.
337
350
 
338
- **Width behavior:** the table targets `120` columns, shrinks `Path`, then
339
- `Commit`, then `Branch` down to their minimums, and drops `Path` (then `Commit`)
340
- on very narrow terminals. Width comes from `COLUMNS`, else the terminal size,
341
- else `120`. `--full` is exempt from truncation.
342
-
343
- **Probe isolation:** each worktree is probed in its own forked child process
344
- (one child per worktree, at most 16 alive, hard 5 second budget per probe).
345
- Probe results are ordered by `git worktree list` order, so output is
346
- deterministic; a failed or timed-out probe renders `?` in `Age`/`Commit`/
351
+ **Width behavior:** the table targets `120` columns, shrinks `Commit`, then
352
+ `Branch` down to their minimums, and drops `Commit` on very narrow terminals.
353
+ Width comes from `COLUMNS`, else the terminal size, else `120`. Path lines are
354
+ never truncated; `--full` is exempt from truncation entirely.
355
+
356
+ **Probe isolation and performance:** each worktree is probed in its own forked
357
+ child process (one child per worktree, at most 16 alive, hard 5 second budget
358
+ per probe). The probe reads only cheap signals: HEAD metadata, tracked changes
359
+ (`git status --untracked-files=no`), committed task-file diffs, and the
360
+ `_current` symlink. The expensive deep untracked scan of `.ace-tasks/` runs as
361
+ a selective second round, only for worktrees the cheap association tiers could
362
+ not resolve. Probe results are ordered by `git worktree list` order, so output
363
+ is deterministic; a failed or timed-out probe renders `?` in `Age`/`Commit`/
347
364
  `Changes` while the rest of the table renders and the command exits 0. Where
348
365
  `fork` is unavailable, probes run sequentially with the same timeouts.
349
366
 
@@ -15,13 +15,24 @@ module Ace
15
15
  # TaskIDExtractor.extract({id: "v.0.9.0+task.121"}) # => "121"
16
16
  # TaskIDExtractor.extract({id: "v.0.9.0+ace-t.121.01"}) # => "121.01"
17
17
  # TaskIDExtractor.extract({id: "v.0.9.0+t.121"}) # => "121"
18
+ # TaskIDExtractor.extract({id: "8xh.t.ddd.0.0"}) # => "ddd.0.0"
18
19
  #
19
20
  # @example Normalize a task reference string
20
21
  # TaskIDExtractor.normalize("v.0.9.0+task.121.01") # => "121.01"
21
22
  # TaskIDExtractor.normalize("task.121") # => "121"
22
23
  # TaskIDExtractor.normalize("ace-t.121") # => "121"
23
24
  # TaskIDExtractor.normalize("121.01") # => "121.01"
25
+ # TaskIDExtractor.normalize("8xh.t.ddd.0.0") # => "ddd.0.0"
24
26
  class TaskIDExtractor
27
+ # B36TS full id with an arbitrary chain of subtask segments:
28
+ # "8xh.t.ddd" -> "ddd", "8xh.t.ddd.0" -> "ddd.0",
29
+ # "8xh.t.ddd.0.0" -> "ddd.0.0". Every task/subtask segment is
30
+ # preserved so the derived branch/worktree name is deterministic
31
+ # and reversible (the segment chain maps 1:1 onto the id).
32
+ B36TS_FULL_ID = /\A[0-9a-z]{3}\.[a-z]\.([0-9a-z]{3}(?:\.[0-9a-z]+)*)\z/
33
+ # Short form of the same chain, optionally inside a longer path
34
+ # (matched at the end): "t.ddd.0.0", "task.hy4", "/x/ace-task.121/".
35
+ SHORT_TASK_ID = /(?:ace-)?(?:task|t)\.([0-9a-z]{3}(?:\.[0-9a-z]+)*)\/?\z/
25
36
  # Extract task ID from task data hash
26
37
  #
27
38
  # @param task_data [Hash] Task data with :id and/or :task_number
@@ -31,8 +42,9 @@ module Ace
31
42
 
32
43
  # Regex with subtask support
33
44
  if task_data[:id]
34
- # B36TS full ID: "8pp.t.hy4" -> "hy4", or subtask "8pp.t.hy4.a" -> "hy4.a"
35
- if match = task_data[:id].match(/\A[0-9a-z]{3}\.[a-z]\.([0-9a-z]{3}(?:\.[a-z0-9])?)\z/)
45
+ # B36TS full ID: "8pp.t.hy4" -> "hy4", subtasks keep every
46
+ # segment: "8pp.t.hy4.a" -> "hy4.a", "8xh.t.ddd.0.0" -> "ddd.0.0"
47
+ if (match = task_data[:id].match(B36TS_FULL_ID))
36
48
  return match[1]
37
49
  end
38
50
  # Try subtask pattern first (e.g., "v.0.9.0+task.121.01" -> "121.01")
@@ -55,18 +67,17 @@ module Ace
55
67
 
56
68
  # Derive the short form of a full Base36 task id.
57
69
  #
58
- # The short form is the three-character suffix plus an optional
59
- # subtask segment (numeric or single character).
70
+ # The short form is the three-character suffix plus the complete
71
+ # subtask segment chain (each segment numeric or alphanumeric,
72
+ # any depth): "8xh.t.ddd.0.0" -> "ddd.0.0".
60
73
  #
61
74
  # @param full_id [String, nil] formatted task id (e.g. "8xg.t.k9y",
62
- # "8wr.t.qk0.3")
63
- # @return [String, nil] short id (e.g. "k9y", "qk0.3") or nil when the
64
- # argument is not a full task id
75
+ # "8wr.t.qk0.3", "8xh.t.ddd.0.0")
76
+ # @return [String, nil] short id (e.g. "k9y", "qk0.3", "ddd.0.0")
77
+ # or nil when the argument is not a full task id
65
78
  def self.short_id(full_id)
66
- match = full_id.to_s.strip.match(/\A[0-9a-z]{3}\.[a-z]\.([0-9a-z]{3})(?:\.([0-9a-z]+))?\z/)
67
- return nil unless match
68
-
69
- match[2] ? "#{match[1]}.#{match[2]}" : match[1]
79
+ match = full_id.to_s.strip.match(B36TS_FULL_ID)
80
+ match && match[1]
70
81
  end
71
82
 
72
83
  # Normalize a task reference string to simple ID format
@@ -78,8 +89,9 @@ module Ace
78
89
  return nil if ref.empty?
79
90
 
80
91
  # Regex patterns for recognized ACE task ID formats
81
- # B36TS full ID: "8pp.t.hy4" -> "hy4", or subtask "8pp.t.hy4.a" -> "hy4.a"
82
- if match = ref.match(/\A[0-9a-z]{3}\.[a-z]\.([0-9a-z]{3}(?:\.[0-9a-z]+)?)\z/)
92
+ # B36TS full ID: "8pp.t.hy4" -> "hy4", subtasks keep every
93
+ # segment: "8pp.t.hy4.a" -> "hy4.a", "8xh.t.ddd.0.0" -> "ddd.0.0"
94
+ if (match = ref.match(B36TS_FULL_ID))
83
95
  match[1]
84
96
  # B36TS short ref: exactly 3 lowercase alphanumeric chars (e.g., "hy4")
85
97
  elsif match = ref.match(/\A([0-9a-z]{3})\z/)
@@ -88,11 +100,14 @@ module Ace
88
100
  # Try hierarchical pattern first (e.g., "121.01", "task.121.01")
89
101
  elsif match = ref.match(/(\d{3})\.(\d{2})(?:\b|$)/)
90
102
  "#{match[1]}.#{match[2]}"
91
- # B36TS short ref with subtask segment (e.g., "qk0.3", "hy4.a")
92
- elsif match = ref.match(/\A([0-9a-z]{3})\.([0-9a-z]+)\z/)
93
- "#{match[1]}.#{match[2]}"
94
- # Try ace-task/ace-t or task/t directory at end of path (e.g., "ace-task.hy4", "/path/to/task.121")
95
- elsif match = ref.match(/(?:ace-)?(?:task|t)\.([0-9a-z]{3})\/?$/)
103
+ # B36TS short ref with the whole subtask segment chain
104
+ # (e.g., "qk0.3", "hy4.a", "ddd.0.0")
105
+ elsif match = ref.match(/\A([0-9a-z]{3}(?:\.[0-9a-z]+)*)\z/)
106
+ match[1]
107
+ # Try task./t. (or ace-task./ace-t.) prefix, keeping the whole
108
+ # subtask segment chain (e.g. "t.ddd.0.0", "task.hy4.a",
109
+ # "/path/to/task.121", "ace-task.hy4/")
110
+ elsif (match = ref.match(SHORT_TASK_ID))
96
111
  match[1]
97
112
  # Try task./t. prefix in middle of path (e.g., ".cache/test/task.121")
98
113
  # Use negative lookbehind to skip ace-task.XXX / ace-t.XXX parent directories
@@ -36,6 +36,8 @@ module Ace
36
36
  @entries = entries.freeze
37
37
  @available = available
38
38
  @project_root = project_root
39
+ @canonical_directories = {}
40
+ @canonical_worktree_paths = {}
39
41
  end
40
42
 
41
43
  attr_reader :entries, :project_root
@@ -92,7 +94,7 @@ module Ace
92
94
 
93
95
  expanded = canonical_path(path)
94
96
  @entries.find do |entry|
95
- entry.worktree_path && canonical_path(entry.worktree_path) == expanded
97
+ entry.worktree_path && canonical_worktree_path(entry) == expanded
96
98
  end
97
99
  end
98
100
 
@@ -114,7 +116,7 @@ module Ace
114
116
  return nil if directory.to_s.empty?
115
117
 
116
118
  expanded = canonical_path(directory)
117
- @entries.find { |entry| canonical_path(entry.directory) == expanded }
119
+ @entries.find { |entry| canonical_directory(entry) == expanded }
118
120
  end
119
121
 
120
122
  # Map changed file paths to task candidates.
@@ -146,11 +148,24 @@ module Ace
146
148
  # under the task directory, including nested spec/artifact folders).
147
149
  def find_by_enclosing_directory(absolute_path)
148
150
  @entries.find do |entry|
149
- directory = canonical_path(entry.directory)
151
+ directory = canonical_directory(entry)
150
152
  absolute_path == directory || absolute_path.start_with?("#{directory}/")
151
153
  end
152
154
  end
153
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
+
154
169
  # Canonical comparison form for paths that may be reached through a
155
170
  # symlinked root (e.g. macOS /var -> /private/var).
156
171
  def canonical_path(value)
@@ -86,6 +86,24 @@ module Ace
86
86
  File.expand_path(value)
87
87
  end
88
88
 
89
+ # Deep-scan fallback for the task tree only: untracked files under
90
+ # `.ace-tasks/`. This walk is expensive on large trees, so the probe
91
+ # itself never performs it; callers run it selectively (second round)
92
+ # for worktrees the cheap association tiers could not resolve.
93
+ #
94
+ # @param path [String] worktree directory
95
+ # @return [Array<String>] repo-relative untracked task paths
96
+ def untracked_task_files(path)
97
+ return [] unless path && File.directory?(path)
98
+
99
+ output = git(path, "ls-files", "--others", "--exclude-standard", "--", TASK_TREE_PREFIX)
100
+ return [] if output.nil?
101
+
102
+ output.lines.map { |line| scrub(line.chomp) }.reject(&:empty?).select { |p| task_tree_path?(p) }
103
+ rescue StandardError
104
+ []
105
+ end
106
+
89
107
  private
90
108
 
91
109
  # @return [Hash, nil] `{sha:, committer_epoch:, subject:}` for HEAD
@@ -101,9 +119,11 @@ module Ace
101
119
  nil
102
120
  end
103
121
 
104
- # @return [Hash, nil] staged/unstaged/untracked counts and changed paths
122
+ # @return [Hash, nil] staged/unstaged counts and changed paths
123
+ # (tracked files only; untracked discovery is opt-in via
124
+ # {#untracked_task_files})
105
125
  def status_snapshot(path)
106
- output = git(path, "status", "--porcelain=v1", "-z", "--untracked-files=all")
126
+ output = git(path, "status", "--porcelain=v1", "-z", "--untracked-files=no")
107
127
  return nil if output.nil?
108
128
 
109
129
  staged = 0
@@ -11,13 +11,18 @@ module Ace
11
11
  module Molecules
12
12
  # Renders the width-aware listing table.
13
13
  #
14
+ # Each worktree renders as three lines: the aligned columns
15
+ # (Task/Branch/Age/Commit/Changes/PR), the never-truncated path
16
+ # indented to the Commit column, and a blank separator line, so each
17
+ # entry is visually grouped and the displayed path is always directly
18
+ # usable (copy/paste, cd).
19
+ #
14
20
  # 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
+ # Branch 25 (min 12), Age 5, Commit 24 (min 10), Changes 7, PR 11.
22
+ # When the target total exceeds the detected width, columns shrink in
23
+ # priority order Commit -> Branch down to their minima, then Commit is
24
+ # dropped. Column lines never exceed the detected width; `--full`
25
+ # prints absolute paths and is exempt from width limits.
21
26
  class WorktreeTableFormatter
22
27
  GUTTER = " "
23
28
  COLUMNS_MIN_WIDTH = 40
@@ -30,12 +35,11 @@ module Ace
30
35
  COMMIT_MIN = 10
31
36
  CHANGES_WIDTH = 7
32
37
  PR_WIDTH = 11
33
- PATH_TARGET = 32
34
- PATH_MIN = 10
35
38
  TASK_MIN = 4
39
+ PATH_INDENT = " "
36
40
 
37
41
  # @param width [Integer] detected terminal width
38
- # @param full [Boolean] exhaustive listing (no truncation)
42
+ # @param full [Boolean] exhaustive listing (absolute paths)
39
43
  # @param env [Hash] environment (HOME shortening, COLUMNS detection)
40
44
  def initialize(width: nil, full: false, env: ENV, stdout: $stdout)
41
45
  @width = width || self.class.detected_width(env: env, stdout: stdout)
@@ -65,75 +69,66 @@ module Ace
65
69
  DEFAULT_WIDTH
66
70
  end
67
71
 
68
- # Render header + separator + rows.
72
+ # Render header + separator + three lines per worktree (columns,
73
+ # indented full path, blank separator).
69
74
  #
70
75
  # @param worktrees [Array<Models::WorktreeInfo>]
71
76
  # @return [Array<String>] output lines
72
77
  def render(worktrees)
73
78
  rows = Array(worktrees)
74
79
  layout = compute_layout(rows)
75
- [header_line(layout), separator_line(layout), *rows.map { |worktree| row_line(worktree, layout) }]
80
+ [
81
+ header_line(layout),
82
+ separator_line(layout),
83
+ *rows.flat_map { |worktree| [row_line(worktree, layout), path_line(worktree, layout), ""] }
84
+ ]
76
85
  end
77
86
 
78
87
  private
79
88
 
80
- Layout = Struct.new(:task, :branch, :age, :commit, :changes, :pr, :path, :total_width,
89
+ Layout = Struct.new(:task, :branch, :age, :commit, :changes, :pr, :total_width,
81
90
  keyword_init: true)
82
91
 
83
92
  def compute_layout(worktrees)
84
93
  task_width = [TASK_MIN, *worktrees.map { |wt| task_cell(wt).length }].max
85
94
  branch_width = BRANCH_TARGET
86
95
  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
96
 
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
97
+ unless @full
98
+ while total_width(task_width, branch_width, commit_width) > @width
99
+ if commit_width > COMMIT_MIN
100
+ commit_width -= 1
101
+ elsif branch_width > BRANCH_MIN
102
+ branch_width -= 1
103
+ else
104
+ break
105
+ end
103
106
  end
104
107
  end
105
108
 
106
- layout = build_layout(task_width, branch_width, commit_width, path_width)
109
+ layout = build_layout(task_width, branch_width, commit_width)
107
110
  return layout if layout[:total_width] <= @width
108
111
 
109
- drop_path(layout)
112
+ drop_commit(layout)
110
113
  end
111
114
 
112
- def build_layout(task_width, branch_width, commit_width, path_width)
113
- total = total_width(task_width, branch_width, commit_width, path_width)
115
+ def build_layout(task_width, branch_width, commit_width)
116
+ total = total_width(task_width, branch_width, commit_width)
114
117
  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)
118
+ changes: CHANGES_WIDTH, pr: PR_WIDTH, total_width: total)
116
119
  end
117
120
 
118
- def total_width(task_width, branch_width, commit_width, path_width)
121
+ def total_width(task_width, branch_width, commit_width)
119
122
  task_width + branch_width + AGE_WIDTH + commit_width + CHANGES_WIDTH + PR_WIDTH +
120
- path_width + (GUTTER.length * 6)
123
+ (GUTTER.length * 5)
121
124
  end
122
125
 
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
-
126
+ # Drop the Commit column when even the minimum layout overflows.
127
+ def drop_commit(layout)
133
128
  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
129
+ task: layout[:task], branch: layout[:branch], age: layout[:age],
130
+ commit: 0, changes: layout[:changes], pr: layout[:pr],
131
+ total_width: layout[:total_width] - layout[:commit] - GUTTER.length
137
132
  )
138
133
  end
139
134
 
@@ -145,7 +140,6 @@ module Ace
145
140
  cells << pad("Commit", layout[:commit], :left) if layout[:commit].positive?
146
141
  cells << pad("Changes", layout[:changes], :right)
147
142
  cells << pad("PR", layout[:pr], :left)
148
- cells << pad("Path", layout[:path], :left) if layout[:path].positive?
149
143
  cells.join(GUTTER).rstrip
150
144
  end
151
145
 
@@ -161,10 +155,28 @@ module Ace
161
155
  cells << pad(commit_cell(worktree, layout[:commit]), layout[:commit], :left) if layout[:commit].positive?
162
156
  cells << pad(changes_cell(worktree), layout[:changes], :right)
163
157
  cells << pad(pr_cell(worktree), layout[:pr], :left)
164
- cells << pad(path_cell(worktree, layout[:path]), layout[:path], :left) if layout[:path].positive?
165
158
  cells.join(GUTTER).rstrip
166
159
  end
167
160
 
161
+ # The full, never-truncated worktree path on its own line, indented
162
+ # to the Commit column so it reads as a continuation of the row
163
+ # (falls back to the Branch column, then a minimal indent).
164
+ def path_line(worktree, layout)
165
+ path = @full ? full_path(worktree) : compact_path(worktree)
166
+ "#{path_indent(layout)}#{path}"
167
+ end
168
+
169
+ def path_indent(layout)
170
+ if layout[:commit].positive?
171
+ " " * (layout[:task] + GUTTER.length + layout[:branch] + GUTTER.length +
172
+ layout[:age] + GUTTER.length)
173
+ elsif layout[:branch].positive?
174
+ " " * (layout[:task] + GUTTER.length)
175
+ else
176
+ PATH_INDENT
177
+ end
178
+ end
179
+
168
180
  # --- Cell contents ---
169
181
 
170
182
  def task_cell(worktree)
@@ -204,13 +216,6 @@ module Ace
204
216
  PrStatusResolver.cell(worktree.pr)
205
217
  end
206
218
 
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
219
  def full_path(worktree)
215
220
  File.expand_path(worktree.path)
216
221
  end
@@ -65,6 +65,7 @@ module Ace
65
65
  project_root = task_index.project_root || @project_root
66
66
 
67
67
  probes = probe_worktrees(raw_worktrees, project_root)
68
+ supplement_untracked_task_files(raw_worktrees, probes, project_root, task_index)
68
69
  enriched = enrich(raw_worktrees, probes, task_index, now, options)
69
70
  selected = apply_filters(enriched, options)
70
71
  active, inactive = selected.partition { |worktree| worktree.active_within_window }
@@ -111,6 +112,40 @@ module Ace
111
112
  runner.run(raw_worktrees)
112
113
  end
113
114
 
115
+ # Second probe round, only for worktrees the cheap association tiers
116
+ # (metadata, `_current` symlink, validated name, tracked task-file
117
+ # changes, committed task files) could not resolve. The deep
118
+ # `.ace-tasks/` untracked scan dominates listing time on large
119
+ # repositories, so it runs selectively and merges its findings back
120
+ # into the round-one payloads (counts + changed task paths) before
121
+ # enrichment classifies activity and renders.
122
+ def supplement_untracked_task_files(raw_worktrees, probes, project_root, task_index)
123
+ return unless task_index.available?
124
+
125
+ resolver = Molecules::TaskAssociationResolver.new(task_index: task_index)
126
+ unresolved = raw_worktrees.each_index.select do |index|
127
+ probe = probes[index]
128
+ probe && resolver.resolve(raw_worktrees[index], probe).task_id.nil?
129
+ end
130
+ return if unresolved.empty?
131
+
132
+ probe_molecule = Molecules::WorktreeProbe.new(project_root: project_root)
133
+ supplement = ->(worktree) { {untracked_task_files: probe_molecule.untracked_task_files(worktree.path)} }
134
+ runner = WorktreeProbeRunner.new(project_root: project_root, probe: supplement)
135
+ payloads = runner.run(unresolved.map { |index| raw_worktrees[index] })
136
+
137
+ unresolved.each_with_index do |probe_index, position|
138
+ payload = payloads[position]
139
+ next if payload.nil? || payload[:untracked_task_files].to_a.empty?
140
+
141
+ paths = payload[:untracked_task_files]
142
+ probe = probes[probe_index]
143
+ probe[:untracked] = probe[:untracked].to_i + paths.length
144
+ probe[:uncommitted_paths] = Array(probe[:uncommitted_paths]) + paths
145
+ probe[:changed_task_files] = Array(probe[:changed_task_files]) + paths
146
+ end
147
+ end
148
+
114
149
  def enrich(raw_worktrees, probes, task_index, now, options)
115
150
  resolver = Molecules::TaskAssociationResolver.new(task_index: task_index)
116
151
  pr_resolver = @pr_resolver || Molecules::PrStatusResolver.new
@@ -252,14 +287,17 @@ module Ace
252
287
  end
253
288
 
254
289
  rows = options[:full] ? (active + inactive) : active
290
+ table_ended_with_blank = false
255
291
  if rows.empty?
256
292
  lines << footer.no_active_message
257
293
  else
258
294
  lines.concat(table.render(rows))
295
+ table_ended_with_blank = true
259
296
  end
260
297
 
261
298
  unless options[:quiet]
262
- lines << ""
299
+ # The table already ends each entry with a blank separator line.
300
+ lines << "" unless table_ended_with_blank
263
301
  lines.concat(footer.lines(
264
302
  inactive: inactive.length,
265
303
  task_associated: statistics[:task_associated],
@@ -272,8 +272,8 @@ module Ace
272
272
  # result = manager.list_all
273
273
  # result = manager.list_all(format: :json)
274
274
  #
275
- # Programmatic inventory for internal consumers (overseer, prune,
276
- # switch/remove hints). The user-facing enriched table is rendered by
275
+ # Programmatic inventory for internal consumers (prune, switch/remove
276
+ # hints). The user-facing enriched table is rendered by
277
277
  # {WorktreeListReport}, not here.
278
278
  #
279
279
  # @option options [Symbol] :format Optional formatting (:simple, :json)
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Worktree
6
- VERSION = "0.27.0"
6
+ VERSION = "0.27.3"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-git-worktree
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.0
4
+ version: 0.27.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-17 00:00:00.000000000 Z
10
+ date: 2026-10-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli