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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -1193
- data/README.md +7 -0
- data/docs/usage.md +39 -22
- data/lib/ace/git/worktree/atoms/task_id_extractor.rb +33 -18
- data/lib/ace/git/worktree/models/task_index.rb +18 -3
- data/lib/ace/git/worktree/molecules/worktree_probe.rb +22 -2
- data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +61 -56
- data/lib/ace/git/worktree/organisms/worktree_list_report.rb +39 -1
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +2 -2
- data/lib/ace/git/worktree/version.rb +1 -1
- metadata +2 -2
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
|

|
|
@@ -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
|
|
296
|
-
|
|
297
|
-
k9y codex/r3-bounded-convergence 17h feat(review): define bo… 2 #142 draft
|
|
298
|
-
|
|
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
|
|
306
|
-
unstaged, or untracked). Bare worktrees are never
|
|
307
|
-
the active/inactive split, so the header and
|
|
308
|
-
filtered set. `Recent branches without
|
|
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
|
|
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 `
|
|
339
|
-
`
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
**Probe isolation:** each worktree is probed in its own forked
|
|
344
|
-
(one child per worktree, at most 16 alive, hard 5 second budget
|
|
345
|
-
|
|
346
|
-
|
|
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",
|
|
35
|
-
|
|
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
|
|
59
|
-
# subtask segment (numeric or
|
|
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")
|
|
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(
|
|
67
|
-
|
|
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",
|
|
82
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 &&
|
|
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|
|
|
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 =
|
|
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
|
|
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=
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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 (
|
|
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 +
|
|
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
|
-
[
|
|
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, :
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
109
|
+
layout = build_layout(task_width, branch_width, commit_width)
|
|
107
110
|
return layout if layout[:total_width] <= @width
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
drop_commit(layout)
|
|
110
113
|
end
|
|
111
114
|
|
|
112
|
-
def build_layout(task_width, branch_width, commit_width
|
|
113
|
-
total = total_width(task_width, branch_width, commit_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,
|
|
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
|
|
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
|
-
|
|
123
|
+
(GUTTER.length * 5)
|
|
121
124
|
end
|
|
122
125
|
|
|
123
|
-
# Drop the
|
|
124
|
-
|
|
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:
|
|
135
|
-
commit: 0, changes:
|
|
136
|
-
total_width:
|
|
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
|
-
|
|
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 (
|
|
276
|
-
#
|
|
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)
|
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.
|
|
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-
|
|
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
|