ace-git-worktree 0.26.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/nav/protocols/skill-sources/ace-git-worktree.yml +0 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +0 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +0 -0
- data/README.md +0 -0
- data/Rakefile +0 -0
- data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
- data/docs/demo/ace-git-worktree-getting-started.tape.yml +0 -0
- data/docs/demo/fixtures/README.md +0 -0
- data/docs/demo/fixtures/sample.txt +0 -0
- data/docs/getting-started.md +7 -4
- data/docs/handbook.md +0 -0
- data/docs/usage.md +141 -24
- data/handbook/agents/worktree.ag.md +10 -6
- data/handbook/skills/as-git-worktree/SKILL.md +0 -0
- data/handbook/skills/as-git-worktree-cleanup/SKILL.md +0 -0
- data/handbook/skills/as-git-worktree-create/SKILL.md +0 -0
- data/handbook/skills/as-git-worktree-manage/SKILL.md +0 -0
- data/handbook/workflow-instructions/git/worktree-create.wf.md +3 -3
- data/handbook/workflow-instructions/git/worktree-manage.wf.md +6 -6
- data/handbook/workflow-instructions/git/worktree.wf.md +2 -2
- data/lib/ace/git/worktree/atoms/duration_parser.rb +81 -0
- data/lib/ace/git/worktree/atoms/git_command.rb +0 -0
- data/lib/ace/git/worktree/atoms/path_expander.rb +0 -0
- data/lib/ace/git/worktree/atoms/relative_age_formatter.rb +59 -0
- data/lib/ace/git/worktree/atoms/slug_generator.rb +0 -0
- data/lib/ace/git/worktree/atoms/task_id_extractor.rb +20 -1
- data/lib/ace/git/worktree/atoms/text_truncator.rb +67 -0
- data/lib/ace/git/worktree/cli/commands/bootstrap.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/cleanup.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/config.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/create.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/list.rb +29 -8
- data/lib/ace/git/worktree/cli/commands/prune.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/remove.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/switch.rb +0 -0
- data/lib/ace/git/worktree/cli.rb +1 -1
- data/lib/ace/git/worktree/commands/bootstrap_command.rb +0 -0
- data/lib/ace/git/worktree/commands/list_command.rb +90 -94
- data/lib/ace/git/worktree/commands/prune_command.rb +0 -0
- data/lib/ace/git/worktree/commands/remove_command.rb +0 -0
- data/lib/ace/git/worktree/commands/switch_command.rb +0 -0
- data/lib/ace/git/worktree/configuration.rb +0 -0
- data/lib/ace/git/worktree/models/task_index.rb +171 -0
- data/lib/ace/git/worktree/models/worktree_info.rb +52 -3
- data/lib/ace/git/worktree/models/worktree_metadata.rb +0 -0
- data/lib/ace/git/worktree/molecules/bootstrap_executor.rb +0 -0
- data/lib/ace/git/worktree/molecules/cleanup_applier.rb +0 -0
- data/lib/ace/git/worktree/molecules/config_loader.rb +0 -0
- data/lib/ace/git/worktree/molecules/current_task_linker.rb +0 -0
- data/lib/ace/git/worktree/molecules/hook_executor.rb +0 -0
- data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +0 -0
- data/lib/ace/git/worktree/molecules/pr_status_resolver.rb +172 -0
- data/lib/ace/git/worktree/molecules/recent_branch_counter.rb +70 -0
- data/lib/ace/git/worktree/molecules/task_association_resolver.rb +121 -0
- data/lib/ace/git/worktree/molecules/task_fetcher.rb +0 -0
- data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -0
- data/lib/ace/git/worktree/molecules/task_pusher.rb +0 -0
- data/lib/ace/git/worktree/molecules/task_status_updater.rb +0 -0
- data/lib/ace/git/worktree/molecules/toolchain_truster.rb +0 -0
- data/lib/ace/git/worktree/molecules/worktree_footer_formatter.rb +43 -0
- data/lib/ace/git/worktree/molecules/worktree_lister.rb +22 -93
- data/lib/ace/git/worktree/molecules/worktree_probe.rb +176 -0
- data/lib/ace/git/worktree/molecules/worktree_remover.rb +0 -0
- data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +236 -0
- data/lib/ace/git/worktree/organisms/worktree_list_report.rb +277 -0
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +22 -20
- data/lib/ace/git/worktree/organisms/worktree_probe_runner.rb +320 -0
- data/lib/ace/git/worktree/version.rb +1 -1
- data/lib/ace/git/worktree.rb +14 -0
- metadata +15 -2
|
@@ -53,6 +53,22 @@ module Ace
|
|
|
53
53
|
task_data[:task_number]&.to_s || "unknown"
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# Derive the short form of a full Base36 task id.
|
|
57
|
+
#
|
|
58
|
+
# The short form is the three-character suffix plus an optional
|
|
59
|
+
# subtask segment (numeric or single character).
|
|
60
|
+
#
|
|
61
|
+
# @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
|
|
65
|
+
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]
|
|
70
|
+
end
|
|
71
|
+
|
|
56
72
|
# Normalize a task reference string to simple ID format
|
|
57
73
|
#
|
|
58
74
|
# @param task_ref [String] Task reference (e.g., "121.01", "v.0.9.0+task.121.01")
|
|
@@ -63,7 +79,7 @@ module Ace
|
|
|
63
79
|
|
|
64
80
|
# Regex patterns for recognized ACE task ID formats
|
|
65
81
|
# B36TS full ID: "8pp.t.hy4" -> "hy4", or subtask "8pp.t.hy4.a" -> "hy4.a"
|
|
66
|
-
if match = ref.match(/\A[0-9a-z]{3}\.[a-z]\.([0-9a-z]{3}(?:\.[
|
|
82
|
+
if match = ref.match(/\A[0-9a-z]{3}\.[a-z]\.([0-9a-z]{3}(?:\.[0-9a-z]+)?)\z/)
|
|
67
83
|
match[1]
|
|
68
84
|
# B36TS short ref: exactly 3 lowercase alphanumeric chars (e.g., "hy4")
|
|
69
85
|
elsif match = ref.match(/\A([0-9a-z]{3})\z/)
|
|
@@ -72,6 +88,9 @@ module Ace
|
|
|
72
88
|
# Try hierarchical pattern first (e.g., "121.01", "task.121.01")
|
|
73
89
|
elsif match = ref.match(/(\d{3})\.(\d{2})(?:\b|$)/)
|
|
74
90
|
"#{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]}"
|
|
75
94
|
# Try ace-task/ace-t or task/t directory at end of path (e.g., "ace-task.hy4", "/path/to/task.121")
|
|
76
95
|
elsif match = ref.match(/(?:ace-)?(?:task|t)\.([0-9a-z]{3})\/?$/)
|
|
77
96
|
match[1]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Worktree
|
|
6
|
+
module Atoms
|
|
7
|
+
# Truncates single-column text using the single-character ellipsis `…`.
|
|
8
|
+
#
|
|
9
|
+
# Each helper returns a string no wider than `width` columns (the
|
|
10
|
+
# ellipsis counts as one column). Text that already fits is returned
|
|
11
|
+
# unchanged. Widths below 1 render an empty string; width 1 renders
|
|
12
|
+
# only the ellipsis.
|
|
13
|
+
#
|
|
14
|
+
# @example Branches keep both ends
|
|
15
|
+
# TextTruncator.middle("codex/ei9-campaign-composition-join", 25)
|
|
16
|
+
# # => "codex/ei9-ca…osition-join"
|
|
17
|
+
module TextTruncator
|
|
18
|
+
ELLIPSIS = "…"
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Keep the prefix and suffix; elide the middle.
|
|
22
|
+
# @param text [String, nil]
|
|
23
|
+
# @param width [Integer] target column width
|
|
24
|
+
# @return [String]
|
|
25
|
+
def middle(text, width)
|
|
26
|
+
value = text.to_s
|
|
27
|
+
return "" if width <= 0
|
|
28
|
+
return value if value.length <= width
|
|
29
|
+
return ELLIPSIS if width == 1
|
|
30
|
+
|
|
31
|
+
remaining = width - 1
|
|
32
|
+
prefix_length = (remaining + 1) / 2
|
|
33
|
+
suffix_length = remaining - prefix_length
|
|
34
|
+
"#{value[0, prefix_length]}#{ELLIPSIS}#{value[-suffix_length, suffix_length]}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Keep the prefix; truncate the tail.
|
|
38
|
+
# @param text [String, nil]
|
|
39
|
+
# @param width [Integer] target column width
|
|
40
|
+
# @return [String]
|
|
41
|
+
def right(text, width)
|
|
42
|
+
value = text.to_s
|
|
43
|
+
return "" if width <= 0
|
|
44
|
+
return value if value.length <= width
|
|
45
|
+
return ELLIPSIS if width == 1
|
|
46
|
+
|
|
47
|
+
"#{value[0, width - 1]}#{ELLIPSIS}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Keep the suffix; elide the head.
|
|
51
|
+
# @param text [String, nil]
|
|
52
|
+
# @param width [Integer] target column width
|
|
53
|
+
# @return [String]
|
|
54
|
+
def left(text, width)
|
|
55
|
+
value = text.to_s
|
|
56
|
+
return "" if width <= 0
|
|
57
|
+
return value if value.length <= width
|
|
58
|
+
return ELLIPSIS if width == 1
|
|
59
|
+
|
|
60
|
+
"#{ELLIPSIS}#{value[-(width - 1), width - 1]}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -12,28 +12,42 @@ module Ace
|
|
|
12
12
|
class List < Ace::Support::Cli::Command
|
|
13
13
|
include SharedHelpers
|
|
14
14
|
|
|
15
|
-
desc
|
|
15
|
+
desc <<~DESC.chomp
|
|
16
|
+
List active worktrees with task, activity, dirty-state, and PR context
|
|
17
|
+
Columns: Task, Branch, Age, Commit, Changes, PR, Path. A worktree is
|
|
18
|
+
active when its last commit is inside --active-within (default 18h) or
|
|
19
|
+
it has uncommitted changes. Task ids are inferred from recorded task
|
|
20
|
+
metadata, the _current symlink, validated path/branch names, or
|
|
21
|
+
.ace-tasks files changed in the worktree; PRs match by branch, then
|
|
22
|
+
HEAD commit sha, then a unique task reference. Columns shrink to the
|
|
23
|
+
terminal width and Path drops on very narrow terminals. Probing runs
|
|
24
|
+
in isolated child processes with a 5 second per-worktree budget;
|
|
25
|
+
unreadable probes render "?".
|
|
26
|
+
DESC
|
|
16
27
|
|
|
17
28
|
example [
|
|
18
|
-
" #
|
|
19
|
-
"--
|
|
20
|
-
"--
|
|
29
|
+
" # Worktrees active in the last 18h",
|
|
30
|
+
"--full # Every worktree with absolute paths",
|
|
31
|
+
"--active-within 3d # Wider activity window",
|
|
32
|
+
"--format json # Machine-readable output",
|
|
21
33
|
"--search auth # Filter by branch pattern"
|
|
22
34
|
]
|
|
23
35
|
|
|
24
36
|
option :format, desc: "Output format: table, json, simple", aliases: [], default: "table"
|
|
25
|
-
option :
|
|
37
|
+
option :compact, desc: "Active worktrees only (default)", type: :boolean
|
|
38
|
+
option :full, desc: "List every worktree with absolute paths", type: :boolean
|
|
39
|
+
option :active_within, desc: "Activity window (Nm, Nh, Nd, or bare hours; default 18h)", aliases: []
|
|
26
40
|
option :task_associated, desc: "Show only task-associated worktrees", type: :boolean
|
|
27
41
|
option :usable, desc: "Show only usable worktrees", type: :boolean
|
|
28
42
|
option :search, desc: "Filter by branch name pattern", aliases: []
|
|
29
|
-
option :quiet, type: :boolean, aliases: ["-q"], desc: "Suppress
|
|
43
|
+
option :quiet, type: :boolean, aliases: ["-q"], desc: "Suppress the count footer"
|
|
30
44
|
option :verbose, type: :boolean, aliases: ["-v"], desc: "Show verbose output"
|
|
31
45
|
option :debug, type: :boolean, aliases: ["-d"], desc: "Show debug output"
|
|
32
46
|
|
|
33
47
|
def call(**options)
|
|
34
48
|
display_config_summary("list", options) unless options[:format] == "json"
|
|
35
49
|
|
|
36
|
-
# Keep explicit false values as --no-* flags so
|
|
50
|
+
# Keep explicit false values as --no-* flags so the parser receives filters.
|
|
37
51
|
args = list_options_to_args(options)
|
|
38
52
|
|
|
39
53
|
Ace::Git::Worktree::Commands::ListCommand.new.run(args)
|
|
@@ -44,7 +58,14 @@ module Ace
|
|
|
44
58
|
def list_options_to_args(options)
|
|
45
59
|
args = []
|
|
46
60
|
args.concat(format_arg(options))
|
|
47
|
-
args << "--
|
|
61
|
+
args << "--compact" if options[:compact]
|
|
62
|
+
args << "--full" if options[:full]
|
|
63
|
+
args << "--quiet" if options[:quiet]
|
|
64
|
+
|
|
65
|
+
if options[:active_within].is_a?(String) && !options[:active_within].empty?
|
|
66
|
+
args << "--active-within"
|
|
67
|
+
args << options[:active_within]
|
|
68
|
+
end
|
|
48
69
|
|
|
49
70
|
unless options[:task_associated].nil?
|
|
50
71
|
args << (options[:task_associated] ? "--task-associated" : "--no-task-associated")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
data/lib/ace/git/worktree/cli.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Ace
|
|
|
38
38
|
|
|
39
39
|
HELP_EXAMPLES = [
|
|
40
40
|
"ace-git-worktree create --task 148 # Isolated worktree for task",
|
|
41
|
-
"ace-git-worktree list --
|
|
41
|
+
"ace-git-worktree list --full # Every worktree with task context",
|
|
42
42
|
"ace-git-worktree switch 148 # Get path for cd",
|
|
43
43
|
"ace-git-worktree prune --dry-run # Preview stale cleanup"
|
|
44
44
|
].freeze
|
|
File without changes
|
|
@@ -6,21 +6,29 @@ module Ace
|
|
|
6
6
|
module Commands
|
|
7
7
|
# List command
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
9
|
+
# Prints the active-worktree table: one enriched row per worktree whose
|
|
10
|
+
# last activity falls inside the activity window (committed or dirty),
|
|
11
|
+
# plus a two-line count footer. `--full` lists every worktree,
|
|
12
|
+
# `--format json` is the machine surface, `--format simple` is the
|
|
13
|
+
# branch-at-path listing.
|
|
11
14
|
#
|
|
12
|
-
# @example
|
|
15
|
+
# @example Daily driver (last 18h)
|
|
13
16
|
# ListCommand.new.run([])
|
|
14
17
|
#
|
|
15
|
-
# @example
|
|
16
|
-
# ListCommand.new.run(["--format", "json"])
|
|
18
|
+
# @example Wider window in JSON
|
|
19
|
+
# ListCommand.new.run(["--active-within", "3d", "--format", "json"])
|
|
17
20
|
#
|
|
18
|
-
# @example
|
|
19
|
-
# ListCommand.new.run(["--
|
|
21
|
+
# @example Exhaustive listing
|
|
22
|
+
# ListCommand.new.run(["--full"])
|
|
20
23
|
class ListCommand
|
|
24
|
+
# Supported output formats
|
|
25
|
+
FORMATS = %w[table json simple].freeze
|
|
26
|
+
|
|
21
27
|
# Initialize a new ListCommand
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
#
|
|
29
|
+
# @param reporter [Organisms::WorktreeListReport, nil] injectable report
|
|
30
|
+
def initialize(reporter: nil)
|
|
31
|
+
@reporter = reporter
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
# Run the list command
|
|
@@ -33,13 +41,10 @@ module Ace
|
|
|
33
41
|
|
|
34
42
|
validate_options(options)
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
options[:format] = options[:format].to_sym if options[:format]
|
|
38
|
-
|
|
39
|
-
result = @manager.list_all(options)
|
|
44
|
+
result = reporter.run(options)
|
|
40
45
|
|
|
41
46
|
if result[:success]
|
|
42
|
-
|
|
47
|
+
puts result[:output] unless result[:output].to_s.empty?
|
|
43
48
|
0
|
|
44
49
|
else
|
|
45
50
|
puts "Failed to list worktrees: #{result[:error]}"
|
|
@@ -47,8 +52,6 @@ module Ace
|
|
|
47
52
|
end
|
|
48
53
|
rescue ArgumentError => e
|
|
49
54
|
puts "Error: #{e.message}"
|
|
50
|
-
puts
|
|
51
|
-
show_help
|
|
52
55
|
1
|
|
53
56
|
rescue => e
|
|
54
57
|
puts "Error: #{e.message}"
|
|
@@ -63,49 +66,61 @@ module Ace
|
|
|
63
66
|
ace-git-worktree list - List worktrees
|
|
64
67
|
|
|
65
68
|
USAGE:
|
|
66
|
-
ace-git-worktree list [OPTIONS]
|
|
69
|
+
ace-git-worktree list [--compact | --full] [--active-within DURATION] [OPTIONS]
|
|
70
|
+
|
|
71
|
+
VIEW:
|
|
72
|
+
--compact Active worktrees only (default)
|
|
73
|
+
--full List every worktree with absolute paths
|
|
74
|
+
--active-within <dur> Activity window: Nm, Nh, Nd, or bare hours (default: 18h)
|
|
67
75
|
|
|
68
|
-
OUTPUT
|
|
76
|
+
OUTPUT:
|
|
69
77
|
--format <format> Output format: table, json, simple (default: table)
|
|
70
|
-
|
|
78
|
+
-q, --quiet Suppress the count footer
|
|
71
79
|
|
|
72
80
|
FILTERING:
|
|
73
|
-
--task-associated Show only task
|
|
74
|
-
--no-task-associated Show only
|
|
75
|
-
--usable
|
|
76
|
-
--no-usable
|
|
81
|
+
--task-associated Show only worktrees with a resolved task id
|
|
82
|
+
--no-task-associated Show only worktrees without a task id
|
|
83
|
+
--usable Show only usable worktrees
|
|
84
|
+
--no-usable Show only unusable worktrees
|
|
77
85
|
--search <pattern> Filter by branch name pattern
|
|
78
86
|
|
|
79
87
|
EXAMPLES:
|
|
80
|
-
#
|
|
88
|
+
# Worktrees active in the last 18 hours
|
|
81
89
|
ace-git-worktree list
|
|
82
90
|
|
|
83
|
-
#
|
|
84
|
-
ace-git-worktree list --
|
|
85
|
-
|
|
86
|
-
#
|
|
87
|
-
ace-git-worktree list --
|
|
88
|
-
|
|
89
|
-
#
|
|
90
|
-
ace-git-worktree list --
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
91
|
+
# Everything, including inactive worktrees
|
|
92
|
+
ace-git-worktree list --full
|
|
93
|
+
|
|
94
|
+
# After a weekend
|
|
95
|
+
ace-git-worktree list --active-within 3d
|
|
96
|
+
|
|
97
|
+
# Machine-readable output
|
|
98
|
+
ace-git-worktree list --format json
|
|
99
|
+
|
|
100
|
+
COLUMNS:
|
|
101
|
+
Task Resolved task id (inferred from metadata, the _current symlink,
|
|
102
|
+
validated names, or task files changed in the worktree)
|
|
103
|
+
Branch Checked-out branch (middle-elided when truncated)
|
|
104
|
+
Age Age of the last commit, one compact unit (8m, 3h, 9d, 3w, 2mo, 1y)
|
|
105
|
+
Commit Last commit subject (right-truncated)
|
|
106
|
+
Changes Uncommitted file count (staged + unstaged + untracked)
|
|
107
|
+
PR #N, #N draft, #N merged, #N closed, or - (matched by branch,
|
|
108
|
+
HEAD commit, or a unique task reference)
|
|
109
|
+
Path Worktree path (~ shortened; --full prints it absolute)
|
|
110
|
+
|
|
111
|
+
Columns shrink to fit the terminal width; Path is dropped on very
|
|
112
|
+
narrow terminals. Probing runs in isolated child processes with a
|
|
113
|
+
5 second per-worktree budget; unreadable probes render "?".
|
|
103
114
|
HELP
|
|
104
115
|
0
|
|
105
116
|
end
|
|
106
117
|
|
|
107
118
|
private
|
|
108
119
|
|
|
120
|
+
def reporter
|
|
121
|
+
@reporter || Organisms::WorktreeListReport.new(env: ENV)
|
|
122
|
+
end
|
|
123
|
+
|
|
109
124
|
# Parse command line arguments
|
|
110
125
|
#
|
|
111
126
|
# @param args [Array<String>] Command arguments
|
|
@@ -113,10 +128,15 @@ module Ace
|
|
|
113
128
|
def parse_arguments(args)
|
|
114
129
|
options = {
|
|
115
130
|
format: "table",
|
|
116
|
-
|
|
131
|
+
compact: false,
|
|
132
|
+
full: false,
|
|
133
|
+
active_within: nil,
|
|
117
134
|
task_associated: nil,
|
|
118
135
|
usable: nil,
|
|
119
136
|
search: nil,
|
|
137
|
+
quiet: false,
|
|
138
|
+
verbose: false,
|
|
139
|
+
debug: false,
|
|
120
140
|
help: false
|
|
121
141
|
}
|
|
122
142
|
|
|
@@ -128,13 +148,17 @@ module Ace
|
|
|
128
148
|
when "--format"
|
|
129
149
|
i += 1
|
|
130
150
|
format = args[i]&.downcase
|
|
131
|
-
|
|
132
|
-
options[:format] = format
|
|
133
|
-
else
|
|
151
|
+
unless FORMATS.include?(format)
|
|
134
152
|
raise ArgumentError, "Invalid format: #{format}. Use: table, json, simple"
|
|
135
153
|
end
|
|
136
|
-
|
|
137
|
-
|
|
154
|
+
options[:format] = format
|
|
155
|
+
when "--compact"
|
|
156
|
+
options[:compact] = true
|
|
157
|
+
when "--full"
|
|
158
|
+
options[:full] = true
|
|
159
|
+
when "--active-within"
|
|
160
|
+
i += 1
|
|
161
|
+
options[:active_within] = args[i]
|
|
138
162
|
when "--task-associated"
|
|
139
163
|
options[:task_associated] = true
|
|
140
164
|
when "--no-task-associated"
|
|
@@ -146,6 +170,12 @@ module Ace
|
|
|
146
170
|
when "--search"
|
|
147
171
|
i += 1
|
|
148
172
|
options[:search] = args[i]
|
|
173
|
+
when "--quiet", "-q"
|
|
174
|
+
options[:quiet] = true
|
|
175
|
+
when "--verbose", "-v"
|
|
176
|
+
options[:verbose] = true
|
|
177
|
+
when "--debug", "-d"
|
|
178
|
+
options[:debug] = true
|
|
149
179
|
when "--help", "-h"
|
|
150
180
|
options[:help] = true
|
|
151
181
|
when /^--/
|
|
@@ -164,14 +194,23 @@ module Ace
|
|
|
164
194
|
#
|
|
165
195
|
# @param options [Hash] Parsed options
|
|
166
196
|
def validate_options(options)
|
|
197
|
+
if options[:compact] && options[:full]
|
|
198
|
+
raise ArgumentError, "--compact and --full are mutually exclusive"
|
|
199
|
+
end
|
|
200
|
+
|
|
167
201
|
if options[:search] && options[:search].empty?
|
|
168
202
|
raise ArgumentError, "Search pattern cannot be empty"
|
|
169
203
|
end
|
|
170
204
|
|
|
171
|
-
if options[:format] &&
|
|
205
|
+
if options[:format] && !FORMATS.include?(options[:format])
|
|
172
206
|
raise ArgumentError, "Invalid format: #{options[:format]}"
|
|
173
207
|
end
|
|
174
208
|
|
|
209
|
+
# Parse eagerly so invalid windows fail before any output.
|
|
210
|
+
if options[:active_within]
|
|
211
|
+
Atoms::DurationParser.parse(options[:active_within])
|
|
212
|
+
end
|
|
213
|
+
|
|
175
214
|
# Security validation for search patterns
|
|
176
215
|
if options[:search] && contains_dangerous_patterns?(options[:search])
|
|
177
216
|
raise ArgumentError, "Search pattern contains potentially dangerous characters"
|
|
@@ -197,49 +236,6 @@ module Ace
|
|
|
197
236
|
|
|
198
237
|
dangerous_patterns.any? { |pattern| value.match?(pattern) }
|
|
199
238
|
end
|
|
200
|
-
|
|
201
|
-
# Display list result
|
|
202
|
-
#
|
|
203
|
-
# @param result [Hash] List result
|
|
204
|
-
# @param options [Hash] Command options
|
|
205
|
-
def display_list_result(result, options)
|
|
206
|
-
if result[:worktrees].empty?
|
|
207
|
-
puts "No worktrees found."
|
|
208
|
-
return
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# Display the formatted output
|
|
212
|
-
puts result[:formatted_output]
|
|
213
|
-
|
|
214
|
-
# Display summary if requested or if not JSON format
|
|
215
|
-
if options[:format] != :json
|
|
216
|
-
display_summary(result, options)
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
# Display summary information
|
|
221
|
-
#
|
|
222
|
-
# @param result [Hash] List result
|
|
223
|
-
# @param options [Hash] Command options
|
|
224
|
-
def display_summary(result, options)
|
|
225
|
-
stats = result[:statistics]
|
|
226
|
-
puts "\nSummary:"
|
|
227
|
-
puts " Total worktrees: #{stats[:total]}"
|
|
228
|
-
puts " Task-associated: #{stats[:task_associated]}"
|
|
229
|
-
puts " Usable: #{stats[:usable]}"
|
|
230
|
-
|
|
231
|
-
if options[:show_tasks] && stats[:task_ids].any?
|
|
232
|
-
puts " Tasks with worktrees: #{stats[:task_ids].join(", ")}"
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
if stats[:branches].any?
|
|
236
|
-
puts " Branches: #{stats[:branches].join(", ")}"
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
# Show active worktrees count
|
|
240
|
-
active_count = result[:worktrees].count { |wt| wt.exists? && wt.usable? }
|
|
241
|
-
puts " Active worktrees: #{active_count}"
|
|
242
|
-
end
|
|
243
239
|
end
|
|
244
240
|
end
|
|
245
241
|
end
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/task_id_extractor"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Worktree
|
|
8
|
+
module Models
|
|
9
|
+
# Immutable index of the current project's tasks, built from the
|
|
10
|
+
# `.ace-tasks/` tree once per listing run.
|
|
11
|
+
#
|
|
12
|
+
# The index maps task spec frontmatter ids (full and short form), task
|
|
13
|
+
# directories, and recorded `worktree.path` / `worktree.branch` metadata
|
|
14
|
+
# so the list command can associate worktrees to tasks without relying
|
|
15
|
+
# on worktree names.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# index = TaskIndexBuilder.new.build
|
|
19
|
+
# index.task?("k9y") # => true
|
|
20
|
+
# index.find_by_worktree_branch("codex/k9y-list") # => Entry
|
|
21
|
+
class TaskIndex
|
|
22
|
+
# One indexed task.
|
|
23
|
+
#
|
|
24
|
+
# @!attribute full_id [String] formatted id (e.g. "8xg.t.k9y")
|
|
25
|
+
# @!attribute short_id [String] short id (e.g. "k9y", "qk0.3")
|
|
26
|
+
# @!attribute directory [String] absolute task directory
|
|
27
|
+
# @!attribute spec_path [String] absolute task spec path
|
|
28
|
+
# @!attribute worktree_path [String, nil] absolute recorded worktree path
|
|
29
|
+
# @!attribute worktree_branch [String, nil] recorded worktree branch
|
|
30
|
+
Entry = Data.define(:full_id, :short_id, :directory, :spec_path, :worktree_path, :worktree_branch)
|
|
31
|
+
|
|
32
|
+
# @param entries [Array<Entry>] indexed tasks (unique short ids)
|
|
33
|
+
# @param available [Boolean] whether a `.ace-tasks/` tree was found
|
|
34
|
+
# @param project_root [String] project root the index was built from
|
|
35
|
+
def initialize(entries: [], available: false, project_root: nil)
|
|
36
|
+
@entries = entries.freeze
|
|
37
|
+
@available = available
|
|
38
|
+
@project_root = project_root
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :entries, :project_root
|
|
42
|
+
|
|
43
|
+
# Whether the project has a `.ace-tasks/` tree.
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def available?
|
|
46
|
+
@available
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def empty?
|
|
51
|
+
@entries.empty?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Find an entry by short or full id.
|
|
55
|
+
#
|
|
56
|
+
# @param reference [String, nil]
|
|
57
|
+
# @return [Entry, nil]
|
|
58
|
+
def entry_for_reference(reference)
|
|
59
|
+
token = reference.to_s.strip
|
|
60
|
+
return nil if token.empty?
|
|
61
|
+
|
|
62
|
+
@entries.find { |entry| entry.full_id == token || entry.short_id == token }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether the reference resolves to a known task.
|
|
66
|
+
#
|
|
67
|
+
# @param reference [String, nil]
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def task?(reference)
|
|
70
|
+
!entry_for_reference(reference).nil?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolve an arbitrary string to a short task id when it names a known
|
|
74
|
+
# task (extracting a task reference from it first).
|
|
75
|
+
#
|
|
76
|
+
# @param reference [String, nil]
|
|
77
|
+
# @return [String, nil]
|
|
78
|
+
def short_id_for(reference)
|
|
79
|
+
entry = entry_for_reference(reference)
|
|
80
|
+
return entry.short_id if entry
|
|
81
|
+
|
|
82
|
+
extracted = Atoms::TaskIDExtractor.normalize(reference)
|
|
83
|
+
extracted && task?(extracted) ? extracted : nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Find the task whose recorded worktree path matches.
|
|
87
|
+
#
|
|
88
|
+
# @param path [String, nil]
|
|
89
|
+
# @return [Entry, nil]
|
|
90
|
+
def find_by_worktree_path(path)
|
|
91
|
+
return nil if path.to_s.empty?
|
|
92
|
+
|
|
93
|
+
expanded = canonical_path(path)
|
|
94
|
+
@entries.find do |entry|
|
|
95
|
+
entry.worktree_path && canonical_path(entry.worktree_path) == expanded
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Find the task whose recorded worktree branch matches.
|
|
100
|
+
#
|
|
101
|
+
# @param branch [String, nil]
|
|
102
|
+
# @return [Entry, nil]
|
|
103
|
+
def find_by_worktree_branch(branch)
|
|
104
|
+
return nil if branch.to_s.empty?
|
|
105
|
+
|
|
106
|
+
@entries.find { |entry| entry.worktree_branch == branch }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Find the task owning an absolute task directory.
|
|
110
|
+
#
|
|
111
|
+
# @param directory [String, nil]
|
|
112
|
+
# @return [Entry, nil]
|
|
113
|
+
def find_by_directory(directory)
|
|
114
|
+
return nil if directory.to_s.empty?
|
|
115
|
+
|
|
116
|
+
expanded = canonical_path(directory)
|
|
117
|
+
@entries.find { |entry| canonical_path(entry.directory) == expanded }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Map changed file paths to task candidates.
|
|
121
|
+
#
|
|
122
|
+
# Paths are relative to the project root (as produced by git). Files
|
|
123
|
+
# changed under a task directory count towards that task; for a
|
|
124
|
+
# changed spec file the candidate id still comes from its directory.
|
|
125
|
+
#
|
|
126
|
+
# @param paths [Array<String>] repo-relative changed paths
|
|
127
|
+
# @return [Array<Hash>] `[{entry:, count:}]` sorted by count desc then
|
|
128
|
+
# short id asc
|
|
129
|
+
def candidates_for_paths(paths)
|
|
130
|
+
counts = Hash.new(0)
|
|
131
|
+
Array(paths).each do |relative_path|
|
|
132
|
+
next if relative_path.to_s.empty?
|
|
133
|
+
|
|
134
|
+
absolute = canonical_path(File.expand_path(relative_path, @project_root || Dir.pwd))
|
|
135
|
+
entry = find_by_enclosing_directory(absolute)
|
|
136
|
+
counts[entry] += 1 if entry
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
counts.map { |entry, count| {entry: entry, count: count} }
|
|
140
|
+
.sort_by { |candidate| [-candidate[:count], candidate[:entry].short_id] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Locate the indexed task directory containing a file path (any depth
|
|
146
|
+
# under the task directory, including nested spec/artifact folders).
|
|
147
|
+
def find_by_enclosing_directory(absolute_path)
|
|
148
|
+
@entries.find do |entry|
|
|
149
|
+
directory = canonical_path(entry.directory)
|
|
150
|
+
absolute_path == directory || absolute_path.start_with?("#{directory}/")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Canonical comparison form for paths that may be reached through a
|
|
155
|
+
# symlinked root (e.g. macOS /var -> /private/var).
|
|
156
|
+
def canonical_path(value)
|
|
157
|
+
expanded = File.expand_path(value)
|
|
158
|
+
return File.realpath(expanded) if File.exist?(expanded)
|
|
159
|
+
|
|
160
|
+
parent = File.dirname(expanded)
|
|
161
|
+
return expanded if parent == expanded
|
|
162
|
+
|
|
163
|
+
File.join(canonical_path(parent), File.basename(expanded))
|
|
164
|
+
rescue StandardError
|
|
165
|
+
File.expand_path(value)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|