ace-git-worktree 0.23.0 → 0.24.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.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Molecules
9
+ # Creates draft pull requests for task worktrees through the
10
+ # forge-neutral provider contract.
11
+ #
12
+ # The caller must supply the exact pushed head SHA (`expected_head`);
13
+ # creation or reuse is proven against that SHA by the provider, never
14
+ # inferred from branch names. Server selection resolves lazily inside
15
+ # the lifecycle organism, so local-only task runs never touch a forge.
16
+ class PullRequestCreator
17
+ # @param server_name [String, nil] explicit server selection
18
+ # @param use_default [Boolean] resolve the configured default server
19
+ # @param remote_name [String, nil] remote used for fallback resolution
20
+ # @param timeout [Integer, nil] provider timeout
21
+ # @param runner [Proc, nil] injectable provider runner (tests)
22
+ def initialize(server_name: nil, use_default: false, remote_name: nil, timeout: nil, runner: nil)
23
+ @selection = {
24
+ server_name: server_name, use_default: use_default, remote_name: remote_name
25
+ }
26
+ @timeout = timeout
27
+ @runner = runner
28
+ end
29
+
30
+ # Create (or reconcile to) a draft PR for a pushed branch.
31
+ #
32
+ # @param branch [String] pushed head branch
33
+ # @param base [String] base branch/ref
34
+ # @param title [String] PR title
35
+ # @param expected_head [String] exact SHA the pushed branch resolves to
36
+ # @param head_repository_url [String, nil] source repository URL
37
+ # (defaults to the selected server's repository)
38
+ # @param body [String, nil] PR body text
39
+ # @return [Hash] {success:, pr_number:, pr_url:, existing:, head_sha:, error:}
40
+ def create_draft(branch:, base:, title:, expected_head:, head_repository_url: nil, body: nil)
41
+ lifecycle = Ace::Git::Organisms::PullRequestLifecycle.new(**@selection.merge(timeout: @timeout, runner: @runner))
42
+ receipt = lifecycle.create(
43
+ head_ref: branch,
44
+ head_repository_url: head_repository_url,
45
+ base_ref: base,
46
+ expected_head: expected_head,
47
+ title: title,
48
+ body: body,
49
+ draft: true
50
+ )
51
+ pr = receipt.pull_request
52
+ {
53
+ success: true,
54
+ pr_number: pr.number,
55
+ pr_url: pr.url,
56
+ existing: receipt.idempotency == :existing,
57
+ head_sha: pr.head_sha,
58
+ error: nil
59
+ }
60
+ rescue Ace::Git::Error => e
61
+ {success: false, pr_number: nil, pr_url: nil, existing: false, head_sha: nil, error: e.message}
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Molecules
9
+ # Resolves normalized pull request evidence for worktree consumers.
10
+ #
11
+ # The server is selected once (explicit `--server`, `--default-server`,
12
+ # or remote resolution) and the PR is fetched through the registered
13
+ # provider contract. Local-only callers never construct this molecule,
14
+ # so no forge resolution happens without a PR-aware request.
15
+ class PullRequestEvidenceResolver
16
+ # @param server_name [String, nil] explicit server selection
17
+ # @param use_default [Boolean] resolve the configured default server
18
+ # @param remote_name [String, nil] git remote for fallback resolution
19
+ # @param timeout [Integer, nil] provider timeout
20
+ # @param runner [Proc, nil] injectable provider runner (tests)
21
+ def initialize(server_name: nil, use_default: false, remote_name: nil, timeout: nil, runner: nil)
22
+ @selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
23
+ @timeout = timeout
24
+ @runner = runner
25
+ end
26
+
27
+ # Fetch exact PR evidence with source/base provenance.
28
+ #
29
+ # @param number [Integer, String] pull request number
30
+ # @return [Hash] {server: ResolvedServer, evidence: ProviderPullRequest}
31
+ def resolve(number)
32
+ server = Ace::Git::ServerRegistry.resolve_for(**@selection)
33
+ provider = Ace::Git::Providers.for(server, timeout: @timeout, runner: @runner)
34
+ {server: server, evidence: provider.pull_request(number: number)}
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -103,20 +103,27 @@ module Ace
103
103
  end
104
104
  end
105
105
 
106
- # Create a worktree for a Pull Request
106
+ # Create a worktree for a Pull Request from an already-verified
107
+ # source ref (see PullRequestCheckoutPreparer).
107
108
  #
108
- # @param pr_data [Hash] PR data hash from PrFetcher
109
+ # @param pr_data [Hash] normalized PR data: :number, :title,
110
+ # :head_branch, :base_branch
109
111
  # @param config [WorktreeConfig] Worktree configuration
110
112
  # @param git_root [String, nil] Git repository root (auto-detected if nil)
113
+ # @param source_ref [String, nil] verified local ref (e.g. FETCH_HEAD)
114
+ # the PR head was proven against; required
115
+ # @param remote_tracking [String, nil] "remote/branch" tracking ref
116
+ # when the source is reachable via a configured local remote
111
117
  # @return [Hash] Result with :success, :worktree_path, :branch, :error
112
118
  #
113
119
  # @example
114
120
  # pr_data = { number: 26, title: "Add feature", head_branch: "feature/auth", base_branch: "main" }
115
- # result = creator.create_for_pr(pr_data, config)
116
- # # => { success: true, worktree_path: "/project/.ace-wt/pr-26", branch: "pr-26", tracking: "origin/feature/auth" }
117
- def create_for_pr(pr_data, config, git_root: nil)
121
+ # result = creator.create_for_pr(pr_data, config, source_ref: "FETCH_HEAD")
122
+ # # => { success: true, worktree_path: ".../ace-pr-26", branch: "pr-26-add-feature", tracking: nil }
123
+ def create_for_pr(pr_data, config, git_root: nil, source_ref: nil, remote_tracking: nil)
118
124
  return error_result("PR data is required") unless pr_data
119
125
  return error_result("Configuration is required") unless config
126
+ return error_result("Verified source ref is required for PR checkout") if source_ref.nil? || source_ref.empty?
120
127
 
121
128
  begin
122
129
  # Determine git repository root
@@ -125,7 +132,6 @@ module Ace
125
132
 
126
133
  # Get PR-specific configuration (fallback to defaults)
127
134
  pr_config = config.pr_config || {}
128
- remote_name = pr_config[:remote_name] || "origin"
129
135
  directory_format = pr_config[:directory_format] || "ace-pr-{number}"
130
136
  branch_format = pr_config[:branch_format] || "pr-{number}-{slug}"
131
137
 
@@ -140,27 +146,28 @@ module Ace
140
146
  validation = validate_worktree_path(worktree_path, git_root)
141
147
  return error_result(validation[:error]) unless validation[:valid]
142
148
 
143
- # Fetch the remote branch
144
- head_branch = pr_data[:head_branch]
145
- fetch_result = fetch_remote_branch(remote_name, head_branch, git_root)
146
- return error_result(fetch_result[:error]) unless fetch_result[:success]
147
-
148
- # Create worktree with remote tracking
149
- result = create_worktree_with_tracking(
150
- worktree_path,
151
- local_branch_name,
152
- "#{remote_name}/#{head_branch}",
153
- git_root,
154
- configure_push: config.configure_push_for_mismatch?
155
- )
149
+ # Create the worktree from the proven source ref
150
+ result = create_worktree(worktree_path, local_branch_name, git_root, start_point: source_ref)
156
151
  return result unless result[:success]
157
152
 
153
+ # Configure push behavior when tracking a differently-named branch
154
+ if remote_tracking && local_branch_name != extract_remote_branch_name(remote_tracking)
155
+ configure_push_for_worktree(worktree_path, local_branch_name, remote_tracking)
156
+ end
157
+
158
+ # Bind the reported tracking ref as the branch's real upstream so
159
+ # `@{upstream}` resolves; advisory when the tracking ref is not a
160
+ # local remote-tracking ref (fork URL fetches).
161
+ upstream = remote_tracking ? set_and_verify_upstream(local_branch_name, remote_tracking) : nil
162
+
158
163
  # Success - return worktree information
159
164
  {
160
165
  success: true,
161
166
  worktree_path: worktree_path,
162
167
  branch: local_branch_name,
163
- tracking: "#{remote_name}/#{head_branch}",
168
+ tracking: remote_tracking,
169
+ upstream: upstream,
170
+ warnings: remote_tracking && !upstream ? ["Could not set upstream to #{remote_tracking}"] : nil,
164
171
  directory_name: directory_name,
165
172
  pr_number: pr_data[:number],
166
173
  pr_title: pr_data[:title],
@@ -764,6 +771,31 @@ module Ace
764
771
  }
765
772
  end
766
773
 
774
+ # Set and verify upstream for a PR worktree branch.
775
+ #
776
+ # @param local_branch [String] the worktree's local branch
777
+ # @param remote_tracking [String] e.g. "origin/feature"
778
+ # @return [String, nil] the verified upstream, or nil when it could
779
+ # not be established (advisory)
780
+ def set_and_verify_upstream(local_branch, remote_tracking)
781
+ require_relative "../atoms/git_command"
782
+
783
+ exists = Atoms::GitCommand.execute(
784
+ "show-ref", "--verify", "--quiet", "refs/remotes/#{remote_tracking}", timeout: 5
785
+ )
786
+ return nil unless exists[:success]
787
+
788
+ result = Atoms::GitCommand.execute(
789
+ "branch", "--set-upstream-to=#{remote_tracking}", local_branch, timeout: 10
790
+ )
791
+ return nil unless result[:success]
792
+
793
+ verify = Atoms::GitCommand.execute(
794
+ "rev-parse", "--abbrev-ref", "#{local_branch}@{upstream}", timeout: 5
795
+ )
796
+ verify[:success] && verify[:output].to_s.strip == remote_tracking ? remote_tracking : nil
797
+ end
798
+
767
799
  # Format PR name using template
768
800
  #
769
801
  # @param template [String] Template string with {variables}
@@ -31,7 +31,6 @@ module Ace
31
31
  @task_committer = Molecules::TaskCommitter.new(project_root: project_root)
32
32
  @task_pusher = Molecules::TaskPusher.new
33
33
  @worktree_creator = Molecules::WorktreeCreator.new
34
- @pr_creator = Molecules::PrCreator.new
35
34
  @parent_task_resolver = Molecules::ParentTaskResolver.new(project_root: project_root)
36
35
  end
37
36
 
@@ -861,14 +860,14 @@ module Ace
861
860
  result[:success] && result[:output]&.include?(branch)
862
861
  end
863
862
 
864
- # Create draft PR for task
863
+ # Create draft PR for task through the forge-neutral provider contract.
865
864
  #
866
- # Creates a draft PR targeting the source branch (start_point) from which
867
- # the worktree branch was created.
865
+ # The exact pushed head SHA is proven to the provider (expected_head);
866
+ # PR identity is never inferred from branch names alone.
868
867
  #
869
868
  # @param task_data [Hash] Task data hash from ace-task
870
869
  # @param worktree_result [Hash] Worktree creation result with :branch, :start_point
871
- # @param options [Hash] Options (may include :source for base branch override)
870
+ # @param options [Hash] Options (may include :source, :server, :default_server)
872
871
  # @return [Hash] Result with :success, :pr_number, :pr_url, :existing, :error
873
872
  def create_pr_for_task(task_data, worktree_result, options)
874
873
  branch = worktree_result[:branch]
@@ -880,14 +879,49 @@ module Ace
880
879
  # Resolve base branch - handle SHA vs branch name
881
880
  base = resolve_pr_base(start_point, options)
882
881
 
883
- # Create draft PR
884
- @pr_creator.create_draft(
882
+ push_remote = options[:push_remote] || @config.push_remote || "origin"
883
+ creator = Molecules::PullRequestCreator.new(
884
+ server_name: options[:server],
885
+ use_default: options[:default_server] == true,
886
+ remote_name: push_remote
887
+ )
888
+ creator.create_draft(
885
889
  branch: branch,
886
890
  base: base,
887
- title: title
891
+ title: title,
892
+ expected_head: pushed_branch_sha(branch, worktree_result[:worktree_path]),
893
+ head_repository_url: remote_url(push_remote)
888
894
  )
889
895
  end
890
896
 
897
+ # Exact SHA of the pushed worktree branch, proven locally before any
898
+ # provider call.
899
+ #
900
+ # @param branch [String] worktree branch name
901
+ # @param worktree_path [String, nil] worktree directory to resolve in
902
+ # @return [String] branch head SHA
903
+ def pushed_branch_sha(branch, worktree_path)
904
+ result = if worktree_path && Dir.exist?(worktree_path)
905
+ Dir.chdir(worktree_path) do
906
+ Atoms::GitCommand.execute("rev-parse", "--verify", "#{branch}^{commit}", timeout: 10)
907
+ end
908
+ else
909
+ Atoms::GitCommand.execute("rev-parse", "--verify", "#{branch}^{commit}", timeout: 10)
910
+ end
911
+ return result[:output].to_s.strip if result[:success]
912
+
913
+ raise Ace::Git::Error, "Cannot prove pushed head SHA for branch '#{branch}': #{result[:error]}"
914
+ end
915
+
916
+ # Local URL of a configured git remote (no network).
917
+ #
918
+ # @param remote [String] remote name
919
+ # @return [String, nil] remote URL
920
+ def remote_url(remote)
921
+ result = Atoms::GitCommand.execute("remote", "get-url", remote, timeout: 10)
922
+ result[:success] ? result[:output].to_s.strip : nil
923
+ end
924
+
891
925
  # Resolve PR base branch from start_point
892
926
  #
893
927
  # If start_point is a commit SHA (not a branch name), creates a branch
@@ -948,7 +982,7 @@ module Ace
948
982
  # Add started_at timestamp to task file IN WORKTREE
949
983
  #
950
984
  # This creates an initial commit in the worktree branch, enabling PR creation
951
- # (GitHub requires at least one commit difference between branches for a PR).
985
+ # (Forges require at least one commit difference between branches for a PR).
952
986
  #
953
987
  # @param task_data [Hash] Task data hash from ace-task
954
988
  # @param worktree_result [Hash] Worktree creation result with :worktree_path
@@ -121,25 +121,31 @@ module Ace
121
121
  end
122
122
  end
123
123
 
124
- # Create a worktree for a Pull Request
124
+ # Create a worktree for a Pull Request from normalized evidence.
125
125
  #
126
- # @param pr_number [Integer] PR number
127
- # @param pr_data [Hash] PR data from Ace::Git::Github::PrFetcher
126
+ # @param evidence [Ace::Git::ProviderPullRequest] normalized PR
127
+ # evidence with exact source/base provenance
128
+ # @param checkout [Hash, nil] verified checkout from
129
+ # PullRequestCheckoutPreparer ({local_ref:, remote_tracking:});
130
+ # nil is valid only for dry runs
128
131
  # @param options [Hash] Options for creation
129
132
  # @return [Hash] Creation result
130
133
  #
131
134
  # @example
132
135
  # manager = WorktreeManager.new
133
- # pr_data = { number: 26, title: "Add feature", head_branch: "feature/auth" }
134
- # result = manager.create_pr(26, pr_data)
135
- def create_pr(pr_number, pr_data, options = {})
136
- return error_result("PR number is required") if pr_number.nil?
137
- return error_result("PR data is required") if pr_data.nil?
136
+ # checkout = preparer.prepare(evidence)
137
+ # result = manager.create_pr(evidence, checkout)
138
+ def create_pr(evidence, checkout, options = {})
139
+ return error_result("PR evidence is required") if evidence.nil?
140
+
141
+ pr_number = evidence.number
142
+ pr_data = normalized_pr_data(evidence)
143
+ local_branch = preview_pr_branch(pr_data)
138
144
 
139
145
  # Check if worktree already exists for this PR's branch
140
- head_branch = pr_data[:head_branch]
141
146
  existing = @worktree_lister.find_by_branch("pr-#{pr_number}") ||
142
- @worktree_lister.find_by_branch(head_branch)
147
+ @worktree_lister.find_by_branch(local_branch) ||
148
+ @worktree_lister.find_by_branch(evidence.head_ref)
143
149
 
144
150
  if existing && !options[:force]
145
151
  return error_result("Worktree already exists at: #{existing.path}")
@@ -150,11 +156,15 @@ module Ace
150
156
  return dry_run_pr_creation(pr_number, pr_data, options)
151
157
  end
152
158
 
159
+ return error_result("Verified checkout is required for PR worktree creation") unless checkout
160
+
153
161
  # Create the worktree
154
162
  result = @worktree_creator.create_for_pr(
155
163
  pr_data,
156
164
  @config,
157
- git_root: @project_root
165
+ git_root: @project_root,
166
+ source_ref: checkout[:local_ref],
167
+ remote_tracking: checkout[:remote_tracking]
158
168
  )
159
169
 
160
170
  if result[:success]
@@ -554,6 +564,40 @@ module Ace
554
564
 
555
565
  # Create error result
556
566
  #
567
+ # Convert normalized PR evidence into the internal pr_data naming
568
+ # format used by configuration templates.
569
+ #
570
+ # @param evidence [Ace::Git::ProviderPullRequest]
571
+ # @return [Hash] {number:, title:, head_branch:, base_branch:}
572
+ def normalized_pr_data(evidence)
573
+ {
574
+ number: evidence.number,
575
+ title: evidence.title,
576
+ head_branch: evidence.head_ref,
577
+ base_branch: evidence.base_ref
578
+ }
579
+ end
580
+
581
+ # Preview the configured local branch name for a PR worktree.
582
+ #
583
+ # @param pr_data [Hash] normalized PR data
584
+ # @return [String] formatted branch name
585
+ def preview_pr_branch(pr_data)
586
+ pr_config = @config.pr_config || {}
587
+ branch_format = pr_config[:branch_format] || "pr-{number}-{slug}"
588
+ require_relative "../atoms/slug_generator"
589
+
590
+ result = branch_format.dup
591
+ result.gsub!("{number}", pr_data[:number].to_s)
592
+ if pr_data[:title]
593
+ slug = Atoms::SlugGenerator.from_title(pr_data[:title])
594
+ result.gsub!("{slug}", slug)
595
+ result.gsub!("{title_slug}", slug)
596
+ end
597
+ result.gsub!("{base_branch}", pr_data[:base_branch].to_s) if pr_data[:base_branch]
598
+ result
599
+ end
600
+
557
601
  # Dry run PR worktree creation
558
602
  #
559
603
  # @param pr_number [Integer] PR number
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Worktree
6
- VERSION = "0.23.0"
6
+ VERSION = "0.24.1"
7
7
  end
8
8
  end
9
9
  end
@@ -190,7 +190,9 @@ module Ace
190
190
 
191
191
  require_relative "worktree/molecules/config_loader"
192
192
  require_relative "worktree/molecules/task_fetcher"
193
- require_relative "worktree/molecules/pr_creator"
193
+ require_relative "worktree/molecules/pull_request_creator"
194
+ require_relative "worktree/molecules/pull_request_evidence_resolver"
195
+ require_relative "worktree/molecules/pull_request_checkout_preparer"
194
196
  require_relative "worktree/molecules/task_committer"
195
197
  require_relative "worktree/molecules/task_pusher"
196
198
  require_relative "worktree/molecules/task_status_updater"
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.23.0
4
+ version: 0.24.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-27 00:00:00.000000000 Z
10
+ date: 2026-10-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -65,20 +65,6 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0.24'
68
- - !ruby/object:Gem::Dependency
69
- name: ace-git-github
70
- requirement: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: 0.1.1
75
- type: :runtime
76
- prerelease: false
77
- version_requirements: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - "~>"
80
- - !ruby/object:Gem::Version
81
- version: 0.1.1
82
68
  - !ruby/object:Gem::Dependency
83
69
  name: ace-task
84
70
  requirement: !ruby/object:Gem::Requirement
@@ -205,7 +191,9 @@ files:
205
191
  - lib/ace/git/worktree/molecules/current_task_linker.rb
206
192
  - lib/ace/git/worktree/molecules/hook_executor.rb
207
193
  - lib/ace/git/worktree/molecules/parent_task_resolver.rb
208
- - lib/ace/git/worktree/molecules/pr_creator.rb
194
+ - lib/ace/git/worktree/molecules/pull_request_checkout_preparer.rb
195
+ - lib/ace/git/worktree/molecules/pull_request_creator.rb
196
+ - lib/ace/git/worktree/molecules/pull_request_evidence_resolver.rb
209
197
  - lib/ace/git/worktree/molecules/task_committer.rb
210
198
  - lib/ace/git/worktree/molecules/task_fetcher.rb
211
199
  - lib/ace/git/worktree/molecules/task_pusher.rb