ace-git-worktree 0.26.1 → 0.27.1

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/nav/protocols/skill-sources/ace-git-worktree.yml +0 -0
  3. data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +0 -0
  4. data/CHANGELOG.md +62 -0
  5. data/LICENSE +0 -0
  6. data/README.md +0 -0
  7. data/Rakefile +0 -0
  8. data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
  9. data/docs/demo/ace-git-worktree-getting-started.tape.yml +0 -0
  10. data/docs/demo/fixtures/README.md +0 -0
  11. data/docs/demo/fixtures/sample.txt +0 -0
  12. data/docs/getting-started.md +7 -4
  13. data/docs/handbook.md +0 -0
  14. data/docs/usage.md +153 -24
  15. data/handbook/agents/worktree.ag.md +10 -6
  16. data/handbook/skills/as-git-worktree/SKILL.md +0 -0
  17. data/handbook/skills/as-git-worktree-cleanup/SKILL.md +0 -0
  18. data/handbook/skills/as-git-worktree-create/SKILL.md +0 -0
  19. data/handbook/skills/as-git-worktree-manage/SKILL.md +0 -0
  20. data/handbook/workflow-instructions/git/worktree-create.wf.md +3 -3
  21. data/handbook/workflow-instructions/git/worktree-manage.wf.md +6 -6
  22. data/handbook/workflow-instructions/git/worktree.wf.md +2 -2
  23. data/lib/ace/git/worktree/atoms/duration_parser.rb +81 -0
  24. data/lib/ace/git/worktree/atoms/git_command.rb +0 -0
  25. data/lib/ace/git/worktree/atoms/path_expander.rb +0 -0
  26. data/lib/ace/git/worktree/atoms/relative_age_formatter.rb +59 -0
  27. data/lib/ace/git/worktree/atoms/slug_generator.rb +0 -0
  28. data/lib/ace/git/worktree/atoms/task_id_extractor.rb +20 -1
  29. data/lib/ace/git/worktree/atoms/text_truncator.rb +67 -0
  30. data/lib/ace/git/worktree/cli/commands/bootstrap.rb +0 -0
  31. data/lib/ace/git/worktree/cli/commands/cleanup.rb +0 -0
  32. data/lib/ace/git/worktree/cli/commands/config.rb +0 -0
  33. data/lib/ace/git/worktree/cli/commands/create.rb +0 -0
  34. data/lib/ace/git/worktree/cli/commands/list.rb +29 -8
  35. data/lib/ace/git/worktree/cli/commands/prune.rb +0 -0
  36. data/lib/ace/git/worktree/cli/commands/remove.rb +0 -0
  37. data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +0 -0
  38. data/lib/ace/git/worktree/cli/commands/switch.rb +0 -0
  39. data/lib/ace/git/worktree/cli.rb +1 -1
  40. data/lib/ace/git/worktree/commands/bootstrap_command.rb +0 -0
  41. data/lib/ace/git/worktree/commands/list_command.rb +90 -94
  42. data/lib/ace/git/worktree/commands/prune_command.rb +0 -0
  43. data/lib/ace/git/worktree/commands/remove_command.rb +0 -0
  44. data/lib/ace/git/worktree/commands/switch_command.rb +0 -0
  45. data/lib/ace/git/worktree/configuration.rb +0 -0
  46. data/lib/ace/git/worktree/models/task_index.rb +186 -0
  47. data/lib/ace/git/worktree/models/worktree_info.rb +52 -3
  48. data/lib/ace/git/worktree/models/worktree_metadata.rb +0 -0
  49. data/lib/ace/git/worktree/molecules/bootstrap_executor.rb +0 -0
  50. data/lib/ace/git/worktree/molecules/cleanup_applier.rb +0 -0
  51. data/lib/ace/git/worktree/molecules/config_loader.rb +0 -0
  52. data/lib/ace/git/worktree/molecules/current_task_linker.rb +0 -0
  53. data/lib/ace/git/worktree/molecules/hook_executor.rb +0 -0
  54. data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +0 -0
  55. data/lib/ace/git/worktree/molecules/pr_status_resolver.rb +172 -0
  56. data/lib/ace/git/worktree/molecules/recent_branch_counter.rb +70 -0
  57. data/lib/ace/git/worktree/molecules/task_association_resolver.rb +121 -0
  58. data/lib/ace/git/worktree/molecules/task_fetcher.rb +0 -0
  59. data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -0
  60. data/lib/ace/git/worktree/molecules/task_pusher.rb +0 -0
  61. data/lib/ace/git/worktree/molecules/task_status_updater.rb +0 -0
  62. data/lib/ace/git/worktree/molecules/toolchain_truster.rb +0 -0
  63. data/lib/ace/git/worktree/molecules/worktree_footer_formatter.rb +43 -0
  64. data/lib/ace/git/worktree/molecules/worktree_lister.rb +22 -93
  65. data/lib/ace/git/worktree/molecules/worktree_probe.rb +196 -0
  66. data/lib/ace/git/worktree/molecules/worktree_remover.rb +0 -0
  67. data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +241 -0
  68. data/lib/ace/git/worktree/organisms/worktree_list_report.rb +315 -0
  69. data/lib/ace/git/worktree/organisms/worktree_manager.rb +22 -20
  70. data/lib/ace/git/worktree/organisms/worktree_probe_runner.rb +320 -0
  71. data/lib/ace/git/worktree/version.rb +1 -1
  72. data/lib/ace/git/worktree.rb +14 -0
  73. metadata +15 -2
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+ require "open3"
6
+ require_relative "../atoms/task_id_extractor"
7
+ require_relative "../models/task_index"
8
+
9
+ module Ace
10
+ module Git
11
+ module Worktree
12
+ module Molecules
13
+ # Builds the `.ace-tasks/` task index once per listing run.
14
+ #
15
+ # Scans every task spec (`*.s.md`) under the project's task tree,
16
+ # including special folders such as `_archive/` and `_maybe/`, and reads
17
+ # only the frontmatter (``id`` plus recorded ``worktree`` metadata).
18
+ # Current-tree entries win over archived entries for the same short id.
19
+ class TaskIndexBuilder
20
+ # File glob for task specs under the task root.
21
+ SPEC_GLOB = "**/*.s.md"
22
+
23
+ # @param project_root [String, nil] project root (defaults to the git
24
+ # common-dir parent of the current repository)
25
+ def initialize(project_root: nil)
26
+ @project_root = project_root
27
+ end
28
+
29
+ # @return [Models::TaskIndex]
30
+ def build
31
+ root = resolved_project_root
32
+ tasks_root = root ? File.join(root, ".ace-tasks") : nil
33
+ unless tasks_root && Dir.exist?(tasks_root)
34
+ return Models::TaskIndex.new(available: false, project_root: root)
35
+ end
36
+
37
+ entries = Dir.glob(File.join(tasks_root, SPEC_GLOB)).filter_map do |spec_path|
38
+ entry_for(spec_path, root)
39
+ end
40
+
41
+ Models::TaskIndex.new(entries: deduplicate(entries), available: true, project_root: root)
42
+ end
43
+
44
+ private
45
+
46
+ # Resolve the project root: the primary working tree that holds
47
+ # `.ace-tasks/`. Uses the git common dir so a linked worktree still
48
+ # reads the project's task tree.
49
+ def resolved_project_root
50
+ return File.expand_path(@project_root) if @project_root
51
+
52
+ out, status = Open3.capture2("git", "rev-parse", "--path-format=absolute", "--git-common-dir")
53
+ return Dir.pwd unless status.success?
54
+
55
+ common_dir = out.to_s.strip
56
+ return Dir.pwd if common_dir.empty?
57
+
58
+ File.dirname(common_dir)
59
+ rescue StandardError
60
+ Dir.pwd
61
+ end
62
+
63
+ # @return [Models::TaskIndex::Entry, nil]
64
+ def entry_for(spec_path, root)
65
+ frontmatter = read_frontmatter(spec_path)
66
+ return nil if frontmatter.nil?
67
+
68
+ full_id = frontmatter["id"].to_s.strip
69
+ short_id = Atoms::TaskIDExtractor.short_id(full_id) ||
70
+ Atoms::TaskIDExtractor.normalize(full_id)
71
+ return nil if short_id.nil? || short_id.empty? || full_id.empty?
72
+
73
+ worktree = frontmatter["worktree"].is_a?(Hash) ? frontmatter["worktree"] : {}
74
+ Models::TaskIndex::Entry.new(
75
+ full_id: full_id,
76
+ short_id: short_id,
77
+ directory: File.dirname(spec_path),
78
+ spec_path: spec_path,
79
+ worktree_path: expand_worktree_path(worktree["path"], root),
80
+ worktree_branch: blank_to_nil(worktree["branch"])
81
+ )
82
+ end
83
+
84
+ # @return [Hash, nil] parsed YAML frontmatter
85
+ def read_frontmatter(spec_path)
86
+ content = File.read(spec_path)
87
+ match = content.match(/\A---\s*\n(.*?)\n---\s*(?:\n|\z)/m)
88
+ return nil unless match
89
+
90
+ YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: true) || {}
91
+ rescue StandardError
92
+ nil
93
+ end
94
+
95
+ def expand_worktree_path(path, root)
96
+ return nil if path.to_s.strip.empty?
97
+
98
+ File.expand_path(path.to_s.strip, root)
99
+ end
100
+
101
+ def blank_to_nil(value)
102
+ value.to_s.strip.empty? ? nil : value.to_s.strip
103
+ end
104
+
105
+ # Keep one entry per short id: the current tree wins over special
106
+ # folders (first path segment starting with "_").
107
+ def deduplicate(entries)
108
+ entries.group_by(&:short_id).map do |_short_id, candidates|
109
+ candidates.min_by { |entry| [archived?(entry) ? 1 : 0, entry.directory] }
110
+ end
111
+ end
112
+
113
+ def archived?(entry)
114
+ relative = entry.directory.sub(%r{\A.*/\.ace-tasks/}, "")
115
+ relative.split("/").any? { |segment| segment.start_with?("_") }
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
File without changes
File without changes
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Worktree
6
+ module Molecules
7
+ # Renders the listing header, empty-state line, and two-line count footer.
8
+ class WorktreeFooterFormatter
9
+ # @param window_label [String] canonical activity window (e.g. "18h")
10
+ # @param full [Boolean] whether every worktree is listed
11
+ def initialize(window_label:, full: false)
12
+ @window_label = window_label
13
+ @full = full
14
+ end
15
+
16
+ # @return [String] e.g. "Active worktrees (last 18h) -- 3 of 88:"
17
+ def header(active_count:, total_count:)
18
+ "Active worktrees (last #{@window_label}) -- #{active_count} of #{total_count}:"
19
+ end
20
+
21
+ # @return [String] empty-state line for a zero-active window
22
+ def no_active_message
23
+ "No active worktrees in the last #{@window_label}."
24
+ end
25
+
26
+ # @return [Array<String>] the count footer lines
27
+ def lines(inactive:, task_associated:, usable:, recent_branches:)
28
+ inactive_line = if @full
29
+ "Inactive: #{inactive}"
30
+ else
31
+ "Inactive: #{inactive} (use --full to list all)"
32
+ end
33
+ [
34
+ inactive_line,
35
+ "Task-associated: #{task_associated} Usable: #{usable} " \
36
+ "Recent branches without worktrees: #{recent_branches}"
37
+ ]
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -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,196 @@
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
+ # 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
+
107
+ private
108
+
109
+ # @return [Hash, nil] `{sha:, committer_epoch:, subject:}` for HEAD
110
+ def head_metadata(path)
111
+ output = git(path, "log", "-1", "--format=%H%x1f%ct%x1f%s")
112
+ return nil if output.nil?
113
+
114
+ sha, epoch, subject = output.chomp.split("\x1f", 3)
115
+ return nil if sha.to_s.empty?
116
+
117
+ {sha: sha, committer_epoch: Integer(epoch, 10), subject: subject.to_s}
118
+ rescue ArgumentError
119
+ nil
120
+ end
121
+
122
+ # @return [Hash, nil] staged/unstaged counts and changed paths
123
+ # (tracked files only; untracked discovery is opt-in via
124
+ # {#untracked_task_files})
125
+ def status_snapshot(path)
126
+ output = git(path, "status", "--porcelain=v1", "-z", "--untracked-files=no")
127
+ return nil if output.nil?
128
+
129
+ staged = 0
130
+ unstaged = 0
131
+ untracked = 0
132
+ paths = []
133
+ records = output.split("\x00")
134
+ index = 0
135
+
136
+ while index < records.length
137
+ record = records[index]
138
+ index += 1
139
+ next if record.nil? || record.length < 4
140
+
141
+ xy = record[0, 2]
142
+ file = scrub(record[3..].to_s)
143
+ # Rename/copy records carry the original path in the next record.
144
+ index += 1 if xy[0] == "R" || xy[0] == "C"
145
+
146
+ if xy == "??"
147
+ untracked += 1
148
+ else
149
+ staged += 1 unless xy[0] == " " || xy[0] == "?"
150
+ unstaged += 1 unless xy[1] == " " || xy[1] == "?"
151
+ end
152
+ paths << file
153
+ end
154
+
155
+ {staged: staged, unstaged: unstaged, untracked: untracked, paths: paths}
156
+ end
157
+
158
+ # Task-tree files changed by this worktree's commits, relative to the
159
+ # merge-base with the primary worktree. Falls back to the HEAD
160
+ # commit's changed files when no merge-base exists.
161
+ #
162
+ # @return [Array<String>] repo-relative `.ace-tasks/` paths
163
+ def committed_task_paths(path, head_sha)
164
+ return [] if head_sha.nil?
165
+
166
+ merge_base = @base_ref ? git(path, "merge-base", @base_ref, head_sha)&.strip : nil
167
+ output = if merge_base && !merge_base.empty?
168
+ git(path, "diff", "--name-only", merge_base, head_sha, "--", ".ace-tasks")
169
+ else
170
+ git(path, "diff-tree", "--no-commit-id", "--name-only", "-r", head_sha, "--", ".ace-tasks")
171
+ end
172
+ return [] if output.nil?
173
+
174
+ output.lines.map { |line| scrub(line.chomp) }.reject(&:empty?).select { |p| task_tree_path?(p) }
175
+ end
176
+
177
+ def task_tree_path?(relative_path)
178
+ relative_path.start_with?(TASK_TREE_PREFIX)
179
+ end
180
+
181
+ # Run git in the worktree; nil on any failure.
182
+ #
183
+ # @return [String, nil]
184
+ def git(path, *args)
185
+ stdout, _stderr, status = Open3.capture3("git", "-C", path, *args)
186
+ status.success? ? stdout : nil
187
+ end
188
+
189
+ def scrub(value)
190
+ value.dup.force_encoding(Encoding::UTF_8).scrub
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
File without changes