@aspruyt/xfg 3.1.2 → 3.1.4

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,112 @@
1
+ import { GitOps } from "./git-ops.js";
2
+ /**
3
+ * Options for authenticated git operations.
4
+ */
5
+ export interface GitAuthOptions {
6
+ /** Access token for authentication */
7
+ token: string;
8
+ /** Git host (e.g., "github.com", "github.mycompany.com") */
9
+ host: string;
10
+ /** Repository owner */
11
+ owner: string;
12
+ /** Repository name */
13
+ repo: string;
14
+ }
15
+ /**
16
+ * Interface for authenticated git operations.
17
+ * Enables proper mocking in tests without relying on class inheritance.
18
+ */
19
+ export interface IAuthenticatedGitOps {
20
+ clone(gitUrl: string): Promise<void>;
21
+ fetch(options?: {
22
+ prune?: boolean;
23
+ }): Promise<void>;
24
+ push(branchName: string, options?: {
25
+ force?: boolean;
26
+ }): Promise<void>;
27
+ getDefaultBranch(): Promise<{
28
+ branch: string;
29
+ method: string;
30
+ }>;
31
+ lsRemote(branchName: string): Promise<string>;
32
+ pushRefspec(refspec: string, options?: {
33
+ delete?: boolean;
34
+ }): Promise<void>;
35
+ fetchBranch(branchName: string): Promise<void>;
36
+ cleanWorkspace(): void;
37
+ createBranch(branchName: string): Promise<void>;
38
+ writeFile(fileName: string, content: string): void;
39
+ setExecutable(fileName: string): Promise<void>;
40
+ getFileContent(fileName: string): string | null;
41
+ wouldChange(fileName: string, content: string): boolean;
42
+ hasChanges(): Promise<boolean>;
43
+ getChangedFiles(): Promise<string[]>;
44
+ hasStagedChanges(): Promise<boolean>;
45
+ fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
46
+ fileExists(fileName: string): boolean;
47
+ deleteFile(fileName: string): void;
48
+ commit(message: string): Promise<boolean>;
49
+ }
50
+ /**
51
+ * Wrapper around GitOps that adds authentication to network operations.
52
+ *
53
+ * When auth options are provided, network operations (clone, fetch, push,
54
+ * getDefaultBranch) use `-c url.insteadOf` to override credentials per-command.
55
+ * This allows different tokens for different repos without global git config.
56
+ *
57
+ * Local operations (commit, writeFile, etc.) pass through unchanged.
58
+ */
59
+ export declare class AuthenticatedGitOps implements IAuthenticatedGitOps {
60
+ private gitOps;
61
+ private auth?;
62
+ private executor;
63
+ private workDir;
64
+ private retries;
65
+ constructor(gitOps: GitOps, auth?: GitAuthOptions);
66
+ private execWithRetry;
67
+ /**
68
+ * Build the authenticated remote URL.
69
+ */
70
+ private getAuthenticatedUrl;
71
+ clone(gitUrl: string): Promise<void>;
72
+ fetch(options?: {
73
+ prune?: boolean;
74
+ }): Promise<void>;
75
+ push(branchName: string, options?: {
76
+ force?: boolean;
77
+ }): Promise<void>;
78
+ getDefaultBranch(): Promise<{
79
+ branch: string;
80
+ method: string;
81
+ }>;
82
+ /**
83
+ * Execute ls-remote with authentication.
84
+ * Used by GraphQLCommitStrategy to check if branch exists on remote.
85
+ */
86
+ lsRemote(branchName: string): Promise<string>;
87
+ /**
88
+ * Execute push with custom refspec (e.g., HEAD:branchName).
89
+ * Used by GraphQLCommitStrategy for creating/deleting remote branches.
90
+ */
91
+ pushRefspec(refspec: string, options?: {
92
+ delete?: boolean;
93
+ }): Promise<void>;
94
+ /**
95
+ * Fetch a specific branch from remote.
96
+ * Used by GraphQLCommitStrategy to update local refs.
97
+ */
98
+ fetchBranch(branchName: string): Promise<void>;
99
+ cleanWorkspace(): void;
100
+ createBranch(branchName: string): Promise<void>;
101
+ writeFile(fileName: string, content: string): void;
102
+ setExecutable(fileName: string): Promise<void>;
103
+ getFileContent(fileName: string): string | null;
104
+ wouldChange(fileName: string, content: string): boolean;
105
+ hasChanges(): Promise<boolean>;
106
+ getChangedFiles(): Promise<string[]>;
107
+ hasStagedChanges(): Promise<boolean>;
108
+ fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
109
+ fileExists(fileName: string): boolean;
110
+ deleteFile(fileName: string): void;
111
+ commit(message: string): Promise<boolean>;
112
+ }
@@ -0,0 +1,170 @@
1
+ import { escapeShellArg } from "./shell-utils.js";
2
+ import { defaultExecutor } from "./command-executor.js";
3
+ import { withRetry } from "./retry-utils.js";
4
+ /**
5
+ * Wrapper around GitOps that adds authentication to network operations.
6
+ *
7
+ * When auth options are provided, network operations (clone, fetch, push,
8
+ * getDefaultBranch) use `-c url.insteadOf` to override credentials per-command.
9
+ * This allows different tokens for different repos without global git config.
10
+ *
11
+ * Local operations (commit, writeFile, etc.) pass through unchanged.
12
+ */
13
+ export class AuthenticatedGitOps {
14
+ gitOps;
15
+ auth;
16
+ executor;
17
+ workDir;
18
+ retries;
19
+ constructor(gitOps, auth) {
20
+ this.gitOps = gitOps;
21
+ this.auth = auth;
22
+ // Extract executor and workDir from gitOps via reflection
23
+ const internal = gitOps;
24
+ this.executor = internal.executor ?? defaultExecutor;
25
+ this.workDir = internal.workDir ?? ".";
26
+ this.retries = internal.retries ?? 3;
27
+ }
28
+ async execWithRetry(command) {
29
+ return withRetry(() => this.executor.exec(command, this.workDir), {
30
+ retries: this.retries,
31
+ });
32
+ }
33
+ // ============================================================
34
+ // Network operations - use authenticated command when token provided
35
+ // ============================================================
36
+ /**
37
+ * Build the authenticated remote URL.
38
+ */
39
+ getAuthenticatedUrl() {
40
+ const { token, host, owner, repo } = this.auth;
41
+ return `https://x-access-token:${token}@${host}/${owner}/${repo}`;
42
+ }
43
+ async clone(gitUrl) {
44
+ if (!this.auth) {
45
+ return this.gitOps.clone(gitUrl);
46
+ }
47
+ // Clone using authenticated URL directly - no insteadOf needed
48
+ const authUrl = escapeShellArg(this.getAuthenticatedUrl());
49
+ await this.execWithRetry(`git clone ${authUrl} .`);
50
+ }
51
+ async fetch(options) {
52
+ if (!this.auth) {
53
+ return this.gitOps.fetch(options);
54
+ }
55
+ // Remote URL already has auth from clone, just fetch
56
+ const pruneFlag = options?.prune ? " --prune" : "";
57
+ await this.execWithRetry(`git fetch origin${pruneFlag}`);
58
+ }
59
+ async push(branchName, options) {
60
+ if (!this.auth) {
61
+ return this.gitOps.push(branchName, options);
62
+ }
63
+ // Remote URL already has auth from clone, just push
64
+ const forceFlag = options?.force ? "--force-with-lease " : "";
65
+ const safeBranch = escapeShellArg(branchName);
66
+ await this.execWithRetry(`git push ${forceFlag}-u origin ${safeBranch}`);
67
+ }
68
+ async getDefaultBranch() {
69
+ if (!this.auth) {
70
+ return this.gitOps.getDefaultBranch();
71
+ }
72
+ // Network operation - remote URL already has auth from clone
73
+ try {
74
+ const remoteInfo = await this.execWithRetry(`git remote show origin`);
75
+ const match = remoteInfo.match(/HEAD branch: (\S+)/);
76
+ if (match) {
77
+ return { branch: match[1], method: "remote HEAD" };
78
+ }
79
+ }
80
+ catch {
81
+ // Fall through to local checks
82
+ }
83
+ // Local operations don't need auth
84
+ try {
85
+ await this.executor.exec("git rev-parse --verify origin/main", this.workDir);
86
+ return { branch: "main", method: "origin/main exists" };
87
+ }
88
+ catch {
89
+ // Continue
90
+ }
91
+ try {
92
+ await this.executor.exec("git rev-parse --verify origin/master", this.workDir);
93
+ return { branch: "master", method: "origin/master exists" };
94
+ }
95
+ catch {
96
+ // Continue
97
+ }
98
+ return { branch: "main", method: "fallback default" };
99
+ }
100
+ /**
101
+ * Execute ls-remote with authentication.
102
+ * Used by GraphQLCommitStrategy to check if branch exists on remote.
103
+ */
104
+ async lsRemote(branchName) {
105
+ // Remote URL already has auth from clone
106
+ const safeBranch = escapeShellArg(branchName);
107
+ return this.execWithRetry(`git ls-remote --exit-code --heads origin ${safeBranch}`);
108
+ }
109
+ /**
110
+ * Execute push with custom refspec (e.g., HEAD:branchName).
111
+ * Used by GraphQLCommitStrategy for creating/deleting remote branches.
112
+ */
113
+ async pushRefspec(refspec, options) {
114
+ // Remote URL already has auth from clone
115
+ const deleteFlag = options?.delete ? "--delete " : "";
116
+ const safeRefspec = escapeShellArg(refspec);
117
+ await this.execWithRetry(`git push ${deleteFlag}-u origin ${safeRefspec}`);
118
+ }
119
+ /**
120
+ * Fetch a specific branch from remote.
121
+ * Used by GraphQLCommitStrategy to update local refs.
122
+ */
123
+ async fetchBranch(branchName) {
124
+ // Remote URL already has auth from clone
125
+ const safeBranch = escapeShellArg(branchName);
126
+ await this.execWithRetry(`git fetch origin ${safeBranch}:refs/remotes/origin/${safeBranch}`);
127
+ }
128
+ // ============================================================
129
+ // Local operations - delegate directly to GitOps
130
+ // ============================================================
131
+ cleanWorkspace() {
132
+ return this.gitOps.cleanWorkspace();
133
+ }
134
+ async createBranch(branchName) {
135
+ return this.gitOps.createBranch(branchName);
136
+ }
137
+ writeFile(fileName, content) {
138
+ return this.gitOps.writeFile(fileName, content);
139
+ }
140
+ async setExecutable(fileName) {
141
+ return this.gitOps.setExecutable(fileName);
142
+ }
143
+ getFileContent(fileName) {
144
+ return this.gitOps.getFileContent(fileName);
145
+ }
146
+ wouldChange(fileName, content) {
147
+ return this.gitOps.wouldChange(fileName, content);
148
+ }
149
+ async hasChanges() {
150
+ return this.gitOps.hasChanges();
151
+ }
152
+ async getChangedFiles() {
153
+ return this.gitOps.getChangedFiles();
154
+ }
155
+ async hasStagedChanges() {
156
+ return this.gitOps.hasStagedChanges();
157
+ }
158
+ async fileExistsOnBranch(fileName, branch) {
159
+ return this.gitOps.fileExistsOnBranch(fileName, branch);
160
+ }
161
+ fileExists(fileName) {
162
+ return this.gitOps.fileExists(fileName);
163
+ }
164
+ deleteFile(fileName) {
165
+ return this.gitOps.deleteFile(fileName);
166
+ }
167
+ async commit(message) {
168
+ return this.gitOps.commit(message);
169
+ }
170
+ }
@@ -1,6 +1,7 @@
1
1
  import { RepoConfig } from "./config.js";
2
2
  import { RepoInfo } from "./repo-detector.js";
3
- import { GitOps, GitOpsOptions } from "./git-ops.js";
3
+ import { GitOpsOptions } from "./git-ops.js";
4
+ import { IAuthenticatedGitOps, GitAuthOptions } from "./authenticated-git-ops.js";
4
5
  import { ILogger } from "./logger.js";
5
6
  import { CommandExecutor } from "./command-executor.js";
6
7
  import { DiffStats } from "./diff-utils.js";
@@ -20,10 +21,10 @@ export interface ProcessorOptions {
20
21
  noDelete?: boolean;
21
22
  }
22
23
  /**
23
- * Factory function type for creating GitOps instances.
24
+ * Factory function type for creating IAuthenticatedGitOps instances.
24
25
  * Allows dependency injection for testing.
25
26
  */
26
- export type GitOpsFactory = (options: GitOpsOptions) => GitOps;
27
+ export type GitOpsFactory = (options: GitOpsOptions, auth?: GitAuthOptions) => IAuthenticatedGitOps;
27
28
  export interface ProcessorResult {
28
29
  success: boolean;
29
30
  repoName: string;
@@ -46,7 +47,7 @@ export declare class RepositoryProcessor {
46
47
  private readonly tokenManager;
47
48
  /**
48
49
  * Creates a new RepositoryProcessor.
49
- * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
50
+ * @param gitOpsFactory - Optional factory for creating AuthenticatedGitOps instances (for testing)
50
51
  * @param log - Optional logger instance (for testing)
51
52
  */
52
53
  constructor(gitOpsFactory?: GitOpsFactory, log?: ILogger);
@@ -4,6 +4,7 @@ import { convertContentToString, } from "./config.js";
4
4
  import { getRepoDisplayName, isGitHubRepo, } from "./repo-detector.js";
5
5
  import { interpolateXfgContent } from "./xfg-template.js";
6
6
  import { GitOps } from "./git-ops.js";
7
+ import { AuthenticatedGitOps, } from "./authenticated-git-ops.js";
7
8
  import { createPR, mergePR } from "./pr-creator.js";
8
9
  import { logger } from "./logger.js";
9
10
  import { getPRStrategy, getCommitStrategy, hasGitHubAppCredentials, } from "./strategies/index.js";
@@ -34,11 +35,13 @@ export class RepositoryProcessor {
34
35
  tokenManager;
35
36
  /**
36
37
  * Creates a new RepositoryProcessor.
37
- * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
38
+ * @param gitOpsFactory - Optional factory for creating AuthenticatedGitOps instances (for testing)
38
39
  * @param log - Optional logger instance (for testing)
39
40
  */
40
41
  constructor(gitOpsFactory, log) {
41
- this.gitOpsFactory = gitOpsFactory ?? ((opts) => new GitOps(opts));
42
+ this.gitOpsFactory =
43
+ gitOpsFactory ??
44
+ ((opts, auth) => new AuthenticatedGitOps(new GitOps(opts), auth));
42
45
  this.log = log ?? logger;
43
46
  // Initialize GitHub App token manager if credentials are configured
44
47
  if (hasGitHubAppCredentials()) {
@@ -63,11 +66,23 @@ export class RepositoryProcessor {
63
66
  skipped: true,
64
67
  };
65
68
  }
69
+ // Build auth options - use installation token OR fall back to GH_TOKEN for PAT flow
70
+ const effectiveToken = token ?? (isGitHubRepo(repoInfo) ? process.env.GH_TOKEN : undefined);
71
+ const authOptions = effectiveToken
72
+ ? {
73
+ token: effectiveToken,
74
+ host: isGitHubRepo(repoInfo)
75
+ ? repoInfo.host
76
+ : "github.com",
77
+ owner: repoInfo.owner,
78
+ repo: repoInfo.repo,
79
+ }
80
+ : undefined;
66
81
  this.gitOps = this.gitOpsFactory({
67
82
  workDir,
68
83
  dryRun,
69
84
  retries: this.retries,
70
- });
85
+ }, authOptions);
71
86
  // Determine merge mode early - affects workflow steps
72
87
  const mergeMode = repoConfig.prOptions?.merge ?? "auto";
73
88
  const isDirectMode = mergeMode === "direct";
@@ -320,6 +335,7 @@ export class RepositoryProcessor {
320
335
  // Use force push (--force-with-lease) for PR branches, not for direct mode
321
336
  force: !isDirectMode,
322
337
  token,
338
+ gitOps: this.gitOps,
323
339
  });
324
340
  this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
325
341
  }
@@ -454,11 +470,23 @@ export class RepositoryProcessor {
454
470
  skipped: true,
455
471
  };
456
472
  }
473
+ // Build auth options - use installation token OR fall back to GH_TOKEN for PAT flow
474
+ const effectiveToken = token ?? (isGitHubRepo(repoInfo) ? process.env.GH_TOKEN : undefined);
475
+ const authOptions = effectiveToken
476
+ ? {
477
+ token: effectiveToken,
478
+ host: isGitHubRepo(repoInfo)
479
+ ? repoInfo.host
480
+ : "github.com",
481
+ owner: repoInfo.owner,
482
+ repo: repoInfo.repo,
483
+ }
484
+ : undefined;
457
485
  this.gitOps = this.gitOpsFactory({
458
486
  workDir,
459
487
  dryRun,
460
488
  retries: this.retries,
461
- });
489
+ }, authOptions);
462
490
  const mergeMode = repoConfig.prOptions?.merge ?? "auto";
463
491
  const isDirectMode = mergeMode === "direct";
464
492
  try {
@@ -534,6 +562,7 @@ export class RepositoryProcessor {
534
562
  retries: this.retries,
535
563
  force: !isDirectMode,
536
564
  token,
565
+ gitOps: this.gitOps,
537
566
  });
538
567
  }
539
568
  catch (error) {
@@ -1,4 +1,5 @@
1
1
  import { RepoInfo } from "../repo-detector.js";
2
+ import { IAuthenticatedGitOps } from "../authenticated-git-ops.js";
2
3
  export interface FileChange {
3
4
  path: string;
4
5
  content: string | null;
@@ -14,6 +15,8 @@ export interface CommitOptions {
14
15
  force?: boolean;
15
16
  /** GitHub App installation token for authentication (used by GraphQLCommitStrategy) */
16
17
  token?: string;
18
+ /** Authenticated git operations wrapper (used by GraphQLCommitStrategy for network ops) */
19
+ gitOps?: IAuthenticatedGitOps;
17
20
  }
18
21
  export interface CommitResult {
19
22
  sha: string;
@@ -18,18 +18,23 @@ export class GitCommitStrategy {
18
18
  * @returns Commit result with SHA and verified: false (no signature)
19
19
  */
20
20
  async commit(options) {
21
- const { branchName, message, workDir, retries = 3, force = true } = options;
21
+ const { branchName, message, workDir, retries = 3, force = true, gitOps, } = options;
22
22
  // Stage all changes
23
23
  await this.executor.exec("git add -A", workDir);
24
24
  // Commit with the message (--no-verify to skip pre-commit hooks)
25
25
  await this.executor.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, workDir);
26
- // Build push command - use --force-with-lease for PR branches, regular push for direct mode
27
- const forceFlag = force ? "--force-with-lease " : "";
28
- const pushCommand = `git push ${forceFlag}-u origin ${escapeShellArg(branchName)}`;
29
- // Push with retry for transient network failures
30
- await withRetry(() => this.executor.exec(pushCommand, workDir), {
31
- retries,
32
- });
26
+ // Push with authentication via gitOps if available
27
+ if (gitOps) {
28
+ await gitOps.push(branchName, { force });
29
+ }
30
+ else {
31
+ // Fallback for non-authenticated scenarios (shouldn't happen in practice)
32
+ const forceFlag = force ? "--force-with-lease " : "";
33
+ const pushCommand = `git push ${forceFlag}-u origin ${escapeShellArg(branchName)}`;
34
+ await withRetry(() => this.executor.exec(pushCommand, workDir), {
35
+ retries,
36
+ });
37
+ }
33
38
  // Get the commit SHA
34
39
  const sha = await this.executor.exec("git rev-parse HEAD", workDir);
35
40
  return {
@@ -44,20 +44,6 @@ export declare class GraphQLCommitStrategy implements CommitStrategy {
44
44
  * Execute the createCommitOnBranch GraphQL mutation.
45
45
  */
46
46
  private executeGraphQLMutation;
47
- /**
48
- * Build a git command with optional token authentication override.
49
- * When a token is provided, uses -c url.insteadOf to override the global
50
- * git config and authenticate with the provided token instead.
51
- *
52
- * This is critical for GitHub App authentication where the global git config
53
- * may have a PAT token embedded, but we need to use the GitHub App installation token.
54
- *
55
- * Uses a repo-specific URL pattern (including owner/repo) so it has a LONGER
56
- * prefix match than the global config and takes precedence.
57
- *
58
- * Applies to all remote operations: push, fetch, ls-remote, etc.
59
- */
60
- private buildAuthenticatedGitCommand;
61
47
  /**
62
48
  * Ensure the branch exists on the remote and matches local HEAD.
63
49
  * createCommitOnBranch requires the branch to already exist.
@@ -69,10 +69,12 @@ export class GraphQLCommitStrategy {
69
69
  throw new Error(`GraphQL payload exceeds 50 MB limit (${Math.round(totalSize / (1024 * 1024))} MB). ` +
70
70
  `Consider using smaller files or the git commit strategy.`);
71
71
  }
72
+ // Get gitOps for authenticated network operations
73
+ const gitOps = options.gitOps;
72
74
  // Ensure the branch exists on remote and is up-to-date with local HEAD
73
75
  // createCommitOnBranch requires the branch to already exist
74
76
  // For PR branches (force=true), we force-update to ensure fresh start from main
75
- await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, token, githubInfo.host, githubInfo.owner, githubInfo.repo);
77
+ await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, gitOps);
76
78
  // Retry loop for expectedHeadOid mismatch
77
79
  let lastError = null;
78
80
  for (let attempt = 0; attempt <= retries; attempt++) {
@@ -80,7 +82,12 @@ export class GraphQLCommitStrategy {
80
82
  // Fetch from remote to ensure we have the latest HEAD
81
83
  // This is critical for expectedHeadOid to match
82
84
  const safeBranch = escapeShellArg(branchName);
83
- await this.executor.exec(this.buildAuthenticatedGitCommand(`fetch origin ${safeBranch}:refs/remotes/origin/${safeBranch}`, token, githubInfo.host, githubInfo.owner, githubInfo.repo), workDir);
85
+ if (gitOps) {
86
+ await gitOps.fetchBranch(branchName);
87
+ }
88
+ else {
89
+ await this.executor.exec(`git fetch origin ${safeBranch}:refs/remotes/origin/${safeBranch}`, workDir);
90
+ }
84
91
  // Get the remote HEAD SHA for this branch (not local HEAD)
85
92
  const headSha = await this.executor.exec(`git rev-parse origin/${safeBranch}`, workDir);
86
93
  // Build and execute the GraphQL mutation
@@ -171,31 +178,6 @@ export class GraphQLCommitStrategy {
171
178
  pushed: true, // GraphQL commits are pushed directly
172
179
  };
173
180
  }
174
- /**
175
- * Build a git command with optional token authentication override.
176
- * When a token is provided, uses -c url.insteadOf to override the global
177
- * git config and authenticate with the provided token instead.
178
- *
179
- * This is critical for GitHub App authentication where the global git config
180
- * may have a PAT token embedded, but we need to use the GitHub App installation token.
181
- *
182
- * Uses a repo-specific URL pattern (including owner/repo) so it has a LONGER
183
- * prefix match than the global config and takes precedence.
184
- *
185
- * Applies to all remote operations: push, fetch, ls-remote, etc.
186
- */
187
- buildAuthenticatedGitCommand(gitArgs, token, host = "github.com", owner, repo) {
188
- if (!token) {
189
- return `git ${gitArgs}`;
190
- }
191
- // Use repo-specific URL pattern for LONGER prefix match to override global config
192
- // Global config: url."https://x-access-token:PAT@github.com/".insteadOf = "https://github.com/"
193
- // Our config: url."https://x-access-token:APP@github.com/owner/repo".insteadOf = "https://github.com/owner/repo"
194
- // The longer prefix (owner/repo) takes precedence in git's URL matching
195
- const repoPath = owner && repo ? `${owner}/${repo}` : "";
196
- const urlOverride = `url."https://x-access-token:${token}@${host}/${repoPath}".insteadOf="https://${host}/${repoPath}"`;
197
- return `git -c ${escapeShellArg(urlOverride)} ${gitArgs}`;
198
- }
199
181
  /**
200
182
  * Ensure the branch exists on the remote and matches local HEAD.
201
183
  * createCommitOnBranch requires the branch to already exist.
@@ -205,23 +187,40 @@ export class GraphQLCommitStrategy {
205
187
  *
206
188
  * For direct mode (force=false): just ensure branch exists.
207
189
  */
208
- async ensureBranchExistsOnRemote(branchName, workDir, force, token, host, owner, repo) {
190
+ async ensureBranchExistsOnRemote(branchName, workDir, force, gitOps) {
209
191
  // Branch name was validated in commit(), safe for shell use
210
192
  try {
211
193
  // Check if the branch exists on remote
212
- await this.executor.exec(this.buildAuthenticatedGitCommand(`ls-remote --exit-code --heads origin ${escapeShellArg(branchName)}`, token, host, owner, repo), workDir);
194
+ if (gitOps) {
195
+ await gitOps.lsRemote(branchName);
196
+ }
197
+ else {
198
+ await this.executor.exec(`git ls-remote --exit-code --heads origin ${escapeShellArg(branchName)}`, workDir);
199
+ }
213
200
  // Branch exists - for PR branches, delete and recreate to ensure fresh from main
214
201
  if (force) {
215
- await this.executor.exec(this.buildAuthenticatedGitCommand(`push origin --delete ${escapeShellArg(branchName)}`, token, host, owner, repo), workDir);
216
- // Now push fresh branch from local HEAD
217
- await this.executor.exec(this.buildAuthenticatedGitCommand(`push -u origin HEAD:${escapeShellArg(branchName)}`, token, host, owner, repo), workDir);
202
+ if (gitOps) {
203
+ await gitOps.pushRefspec(branchName, { delete: true });
204
+ // Now push fresh branch from local HEAD
205
+ await gitOps.pushRefspec(`HEAD:${branchName}`);
206
+ }
207
+ else {
208
+ await this.executor.exec(`git push origin --delete ${escapeShellArg(branchName)}`, workDir);
209
+ // Now push fresh branch from local HEAD
210
+ await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
211
+ }
218
212
  }
219
213
  // For direct mode (force=false), leave existing branch as-is
220
214
  }
221
215
  catch {
222
216
  // Branch doesn't exist on remote, push it
223
217
  // This pushes the current local branch to create it on remote
224
- await this.executor.exec(this.buildAuthenticatedGitCommand(`push -u origin HEAD:${escapeShellArg(branchName)}`, token, host, owner, repo), workDir);
218
+ if (gitOps) {
219
+ await gitOps.pushRefspec(`HEAD:${branchName}`);
220
+ }
221
+ else {
222
+ await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
223
+ }
225
224
  }
226
225
  }
227
226
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
4
4
  "description": "CLI tool for repository-as-code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",