@aspruyt/xfg 6.0.3 → 6.2.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.
Files changed (143) hide show
  1. package/dist/cli/lifecycle-report-builder.d.ts +2 -2
  2. package/dist/cli/lifecycle-report-builder.js +3 -11
  3. package/dist/cli/program.d.ts +2 -1
  4. package/dist/cli/program.js +2 -3
  5. package/dist/cli/repo-sync-runner.d.ts +24 -0
  6. package/dist/cli/repo-sync-runner.js +156 -0
  7. package/dist/cli/results-collector.d.ts +1 -1
  8. package/dist/cli/results-collector.js +2 -2
  9. package/dist/cli/settings-factories.d.ts +7 -0
  10. package/dist/cli/settings-factories.js +27 -0
  11. package/dist/cli/settings-report-builder.d.ts +1 -1
  12. package/dist/cli/settings-report-builder.js +12 -23
  13. package/dist/cli/settings-runner.d.ts +2 -0
  14. package/dist/cli/settings-runner.js +87 -0
  15. package/dist/cli/sync-command.d.ts +1 -1
  16. package/dist/cli/sync-command.js +31 -372
  17. package/dist/cli/sync-report-builder.d.ts +1 -1
  18. package/dist/cli/sync-utils.d.ts +8 -0
  19. package/dist/cli/sync-utils.js +36 -0
  20. package/dist/cli/types.d.ts +5 -7
  21. package/dist/cli/unified-summary.d.ts +1 -3
  22. package/dist/cli/unified-summary.js +7 -5
  23. package/dist/cli.js +2 -1
  24. package/dist/{shared → config}/env.js +2 -2
  25. package/dist/config/extends-resolver.js +4 -3
  26. package/dist/config/file-reference-resolver.js +4 -2
  27. package/dist/config/formatter.js +18 -1
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/loader.js +30 -6
  30. package/dist/config/merge.d.ts +11 -1
  31. package/dist/config/merge.js +78 -6
  32. package/dist/config/normalizer.js +53 -38
  33. package/dist/config/validator.d.ts +1 -4
  34. package/dist/config/validator.js +13 -599
  35. package/dist/config/validators/file-validator.d.ts +2 -1
  36. package/dist/config/validators/file-validator.js +9 -1
  37. package/dist/config/validators/group-validator.d.ts +3 -0
  38. package/dist/config/validators/group-validator.js +167 -0
  39. package/dist/config/validators/repo-entry-validator.d.ts +2 -0
  40. package/dist/config/validators/repo-entry-validator.js +165 -0
  41. package/dist/config/validators/repo-settings-validator.js +18 -7
  42. package/dist/config/validators/ruleset-validator.js +2 -5
  43. package/dist/config/validators/shared.d.ts +11 -0
  44. package/dist/config/validators/shared.js +242 -0
  45. package/dist/lifecycle/ado-migration-source.js +2 -4
  46. package/dist/lifecycle/github-lifecycle-provider.d.ts +7 -11
  47. package/dist/lifecycle/github-lifecycle-provider.js +125 -136
  48. package/dist/lifecycle/{lifecycle-helpers.d.ts → helpers.d.ts} +5 -1
  49. package/dist/lifecycle/{lifecycle-helpers.js → helpers.js} +9 -8
  50. package/dist/lifecycle/index.d.ts +2 -2
  51. package/dist/lifecycle/index.js +1 -1
  52. package/dist/lifecycle/repo-lifecycle-factory.d.ts +2 -2
  53. package/dist/output/github-summary.js +2 -3
  54. package/dist/output/index.d.ts +4 -0
  55. package/dist/output/index.js +4 -0
  56. package/dist/output/lifecycle-report.d.ts +1 -1
  57. package/dist/output/lifecycle-report.js +5 -0
  58. package/dist/output/sync-report.d.ts +25 -3
  59. package/dist/output/sync-report.js +11 -11
  60. package/dist/settings/base-processor.d.ts +18 -7
  61. package/dist/settings/base-processor.js +26 -5
  62. package/dist/settings/code-scanning/diff.js +2 -2
  63. package/dist/settings/code-scanning/formatter.d.ts +2 -6
  64. package/dist/settings/code-scanning/formatter.js +2 -25
  65. package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +3 -7
  66. package/dist/settings/code-scanning/github-code-scanning-strategy.js +2 -2
  67. package/dist/settings/code-scanning/processor.js +6 -4
  68. package/dist/settings/code-scanning/types.d.ts +10 -8
  69. package/dist/settings/labels/github-labels-strategy.d.ts +3 -11
  70. package/dist/settings/labels/types.d.ts +12 -10
  71. package/dist/settings/repo-settings/diff.d.ts +1 -1
  72. package/dist/settings/repo-settings/diff.js +1 -1
  73. package/dist/settings/repo-settings/formatter.d.ts +2 -6
  74. package/dist/settings/repo-settings/formatter.js +4 -23
  75. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +2 -2
  76. package/dist/settings/repo-settings/github-repo-settings-strategy.js +8 -7
  77. package/dist/settings/repo-settings/processor.js +11 -11
  78. package/dist/settings/repo-settings/types.d.ts +2 -2
  79. package/dist/settings/rulesets/diff-algorithm.js +4 -2
  80. package/dist/settings/rulesets/diff.js +2 -51
  81. package/dist/settings/rulesets/formatter.js +4 -0
  82. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +3 -3
  83. package/dist/settings/rulesets/github-ruleset-strategy.js +4 -6
  84. package/dist/settings/rulesets/index.d.ts +1 -1
  85. package/dist/settings/rulesets/index.js +0 -2
  86. package/dist/settings/rulesets/processor.js +1 -1
  87. package/dist/settings/rulesets/types.d.ts +6 -2
  88. package/dist/shared/command-executor.d.ts +4 -4
  89. package/dist/shared/command-executor.js +9 -7
  90. package/dist/shared/diff-format.d.ts +1 -0
  91. package/dist/shared/diff-format.js +10 -0
  92. package/dist/shared/errors.d.ts +7 -4
  93. package/dist/shared/errors.js +8 -8
  94. package/dist/shared/gh-api-utils.d.ts +3 -34
  95. package/dist/shared/gh-api-utils.js +23 -53
  96. package/dist/shared/gh-token-utils.d.ts +26 -0
  97. package/dist/shared/gh-token-utils.js +32 -0
  98. package/dist/shared/json-utils.js +1 -1
  99. package/dist/shared/regex-utils.d.ts +1 -0
  100. package/dist/shared/regex-utils.js +3 -0
  101. package/dist/shared/retry-utils.d.ts +1 -0
  102. package/dist/shared/retry-utils.js +13 -7
  103. package/dist/sync/auth-options-builder.js +1 -1
  104. package/dist/sync/branch-manager.js +5 -3
  105. package/dist/sync/commit-push-manager.js +2 -3
  106. package/dist/sync/diff-utils.d.ts +0 -1
  107. package/dist/sync/diff-utils.js +5 -10
  108. package/dist/sync/file-sync-orchestrator.js +0 -2
  109. package/dist/sync/file-writer.d.ts +3 -0
  110. package/dist/sync/file-writer.js +84 -81
  111. package/dist/sync/index.d.ts +0 -1
  112. package/dist/sync/index.js +0 -1
  113. package/dist/sync/manifest.js +1 -1
  114. package/dist/sync/pr-merge-handler.js +6 -6
  115. package/dist/sync/sync-workflow.js +1 -1
  116. package/dist/sync/types.d.ts +2 -2
  117. package/dist/vcs/ado-pr-strategy.d.ts +3 -5
  118. package/dist/vcs/ado-pr-strategy.js +131 -33
  119. package/dist/vcs/authenticated-git-ops.js +45 -23
  120. package/dist/vcs/git-commit-strategy.js +10 -6
  121. package/dist/vcs/git-ops.js +30 -24
  122. package/dist/vcs/github-pr-strategy.d.ts +3 -2
  123. package/dist/vcs/github-pr-strategy.js +80 -30
  124. package/dist/vcs/gitlab-pr-strategy.d.ts +2 -5
  125. package/dist/vcs/gitlab-pr-strategy.js +88 -87
  126. package/dist/vcs/graphql-commit-strategy.d.ts +1 -5
  127. package/dist/vcs/graphql-commit-strategy.js +21 -37
  128. package/dist/vcs/pr-creator.js +9 -2
  129. package/dist/vcs/pr-strategy.d.ts +2 -3
  130. package/dist/vcs/pr-strategy.js +0 -1
  131. package/dist/vcs/types.d.ts +9 -5
  132. package/package.json +5 -5
  133. package/dist/config/validators/index.d.ts +0 -3
  134. package/dist/config/validators/index.js +0 -6
  135. package/dist/output/types.d.ts +0 -20
  136. package/dist/output/types.js +0 -1
  137. package/dist/shared/shell-utils.d.ts +0 -6
  138. package/dist/shared/shell-utils.js +0 -17
  139. /package/dist/{shared → config}/env.d.ts +0 -0
  140. /package/dist/lifecycle/{lifecycle-formatter.d.ts → formatter.d.ts} +0 -0
  141. /package/dist/lifecycle/{lifecycle-formatter.js → formatter.js} +0 -0
  142. /package/dist/{vcs → shared}/sanitize-utils.d.ts +0 -0
  143. /package/dist/{vcs → shared}/sanitize-utils.js +0 -0
@@ -1,6 +1,5 @@
1
1
  import { existsSync, writeFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { escapeShellArg } from "../shared/shell-utils.js";
4
3
  import { assertAzureDevOpsRepo, } from "../repo/index.js";
5
4
  import { SyncError } from "../shared/errors.js";
6
5
  import { BasePRStrategy } from "./pr-strategy.js";
@@ -8,13 +7,10 @@ import { withRetry, isPermanentError } from "../shared/retry-utils.js";
8
7
  import { toErrorMessage } from "../shared/type-guards.js";
9
8
  import { safeCleanup } from "../shared/cleanup-utils.js";
10
9
  import { NO_OP_DEBUG_LOG } from "../shared/logger.js";
11
- import { sanitizeCredentials } from "./sanitize-utils.js";
10
+ import { sanitizeCredentials } from "../shared/sanitize-utils.js";
12
11
  import { getStderr } from "../shared/command-executor.js";
13
12
  export class AdoPRStrategy extends BasePRStrategy {
14
- constructor(executor, log) {
15
- super(executor, log);
16
- this.bodyFilePath = ".pr-description.md";
17
- }
13
+ bodyFilePath = ".pr-description.md";
18
14
  getOrgUrl(repoInfo) {
19
15
  return `https://dev.azure.com/${encodeURIComponent(repoInfo.organization)}`;
20
16
  }
@@ -27,9 +23,27 @@ export class AdoPRStrategy extends BasePRStrategy {
27
23
  */
28
24
  async findExistingPRId(azureRepoInfo, branchName, baseBranch, workDir, retries) {
29
25
  const orgUrl = this.getOrgUrl(azureRepoInfo);
30
- const command = `az repos pr list --repository ${escapeShellArg(azureRepoInfo.repo)} --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --query "[0].pullRequestId" -o tsv`;
26
+ const args = [
27
+ "repos",
28
+ "pr",
29
+ "list",
30
+ "--repository",
31
+ azureRepoInfo.repo,
32
+ "--source-branch",
33
+ branchName,
34
+ "--target-branch",
35
+ baseBranch,
36
+ "--org",
37
+ orgUrl,
38
+ "--project",
39
+ azureRepoInfo.project,
40
+ "--query",
41
+ "[0].pullRequestId",
42
+ "-o",
43
+ "tsv",
44
+ ];
31
45
  try {
32
- const existingPRId = await withRetry(() => this.executor.exec(command, workDir), { retries, log: this.log });
46
+ const existingPRId = await withRetry(() => this.executor.exec("az", args, workDir), { retries, log: this.log });
33
47
  return existingPRId ? existingPRId.trim() : null;
34
48
  }
35
49
  catch (error) {
@@ -57,12 +71,21 @@ export class AdoPRStrategy extends BasePRStrategy {
57
71
  const orgUrl = this.getOrgUrl(azureRepoInfo);
58
72
  const prId = await this.findExistingPRId(azureRepoInfo, branchName, baseBranch, workDir, retries);
59
73
  if (!prId) {
60
- return false;
74
+ return { status: "no_pr" };
61
75
  }
62
- // Abandon the PR (Azure DevOps equivalent of closing)
63
- const abandonCommand = `az repos pr update --id ${escapeShellArg(prId)} --status abandoned --org ${escapeShellArg(orgUrl)}`;
76
+ const abandonArgs = [
77
+ "repos",
78
+ "pr",
79
+ "update",
80
+ "--id",
81
+ prId,
82
+ "--status",
83
+ "abandoned",
84
+ "--org",
85
+ orgUrl,
86
+ ];
64
87
  try {
65
- await withRetry(() => this.executor.exec(abandonCommand, workDir), {
88
+ await withRetry(() => this.executor.exec("az", abandonArgs, workDir), {
66
89
  retries,
67
90
  log: this.log,
68
91
  });
@@ -70,22 +93,52 @@ export class AdoPRStrategy extends BasePRStrategy {
70
93
  catch (error) {
71
94
  const message = toErrorMessage(error);
72
95
  this.log?.warn(`Failed to abandon PR #${prId}: ${message}`);
73
- return false;
96
+ return { status: "close_failed", message };
74
97
  }
75
98
  try {
76
- const getRefCommand = `az repos ref list --repository ${escapeShellArg(azureRepoInfo.repo)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --filter heads/${escapeShellArg(branchName)} --query "[0].objectId" -o tsv`;
77
- const objectId = await withRetry(() => this.executor.exec(getRefCommand, workDir), { retries, log: this.log });
99
+ const getRefArgs = [
100
+ "repos",
101
+ "ref",
102
+ "list",
103
+ "--repository",
104
+ azureRepoInfo.repo,
105
+ "--org",
106
+ orgUrl,
107
+ "--project",
108
+ azureRepoInfo.project,
109
+ "--filter",
110
+ `heads/${branchName}`,
111
+ "--query",
112
+ "[0].objectId",
113
+ "-o",
114
+ "tsv",
115
+ ];
116
+ const objectId = await withRetry(() => this.executor.exec("az", getRefArgs, workDir), { retries, log: this.log });
78
117
  if (objectId) {
79
- const deleteBranchCommand = `az repos ref delete --name refs/heads/${escapeShellArg(branchName)} --repository ${escapeShellArg(azureRepoInfo.repo)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --object-id ${escapeShellArg(objectId)}`;
80
- await withRetry(() => this.executor.exec(deleteBranchCommand, workDir), { retries, log: this.log });
118
+ const deleteBranchArgs = [
119
+ "repos",
120
+ "ref",
121
+ "delete",
122
+ "--name",
123
+ `refs/heads/${branchName}`,
124
+ "--repository",
125
+ azureRepoInfo.repo,
126
+ "--org",
127
+ orgUrl,
128
+ "--project",
129
+ azureRepoInfo.project,
130
+ "--object-id",
131
+ objectId,
132
+ ];
133
+ await withRetry(() => this.executor.exec("az", deleteBranchArgs, workDir), { retries, log: this.log });
81
134
  }
82
135
  }
83
136
  catch (error) {
84
- // Branch deletion failure is not critical - PR is already abandoned
85
- const message = toErrorMessage(error);
86
- this.log?.warn(`Failed to delete branch ${branchName}: ${message}`);
137
+ const message = `PR #${prId} abandoned but branch ${branchName} deletion failed: ${toErrorMessage(error)}`;
138
+ this.log?.warn(message);
139
+ return { status: "close_failed", message };
87
140
  }
88
- return true;
141
+ return { status: "closed" };
89
142
  }
90
143
  async create(options) {
91
144
  const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
@@ -97,12 +150,33 @@ export class AdoPRStrategy extends BasePRStrategy {
97
150
  writeFileSync(descFile, body, "utf-8");
98
151
  }
99
152
  catch (err) {
100
- throw new SyncError(`Failed to write PR description to ${descFile}: ${toErrorMessage(err)}`);
153
+ throw new SyncError(`Failed to write PR description to ${descFile}: ${toErrorMessage(err)}`, { cause: err });
101
154
  }
102
- // Azure CLI @file syntax: escape the full @path to handle special chars in workDir
103
- const command = `az repos pr create --repository ${escapeShellArg(azureRepoInfo.repo)} --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --title ${escapeShellArg(title)} --description ${escapeShellArg("@" + descFile)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --query "pullRequestId" -o tsv`;
155
+ const args = [
156
+ "repos",
157
+ "pr",
158
+ "create",
159
+ "--repository",
160
+ azureRepoInfo.repo,
161
+ "--source-branch",
162
+ branchName,
163
+ "--target-branch",
164
+ baseBranch,
165
+ "--title",
166
+ title,
167
+ "--description",
168
+ `@${descFile}`,
169
+ "--org",
170
+ orgUrl,
171
+ "--project",
172
+ azureRepoInfo.project,
173
+ "--query",
174
+ "pullRequestId",
175
+ "-o",
176
+ "tsv",
177
+ ];
104
178
  try {
105
- const prId = await withRetry(() => this.executor.exec(command, workDir), {
179
+ const prId = await withRetry(() => this.executor.exec("az", args, workDir), {
106
180
  retries,
107
181
  log: this.log,
108
182
  });
@@ -152,13 +226,21 @@ export class AdoPRStrategy extends BasePRStrategy {
152
226
  };
153
227
  }
154
228
  const orgUrl = `https://dev.azure.com/${encodeURIComponent(prInfo.organization)}`;
155
- const squashFlag = config.strategy === "squash" ? "--squash true" : "";
156
- const deleteBranchFlag = config.deleteBranch
157
- ? "--delete-source-branch true"
158
- : "";
159
229
  if (config.mode === "auto") {
160
- const autoCommand = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --auto-complete true ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}`.trim();
161
- return this.executeMergeCommand(() => this.executor.exec(autoCommand, workDir), retries, {
230
+ const autoArgs = [
231
+ "repos",
232
+ "pr",
233
+ "update",
234
+ "--id",
235
+ prInfo.prId,
236
+ "--auto-complete",
237
+ "true",
238
+ ...(config.strategy === "squash" ? ["--squash", "true"] : []),
239
+ ...(config.deleteBranch ? ["--delete-source-branch", "true"] : []),
240
+ "--org",
241
+ orgUrl,
242
+ ];
243
+ return this.executeMergeCommand(() => this.executor.exec("az", autoArgs, workDir), retries, {
162
244
  success: true,
163
245
  message: "Auto-complete enabled. PR will merge when all policies pass.",
164
246
  merged: false,
@@ -168,8 +250,24 @@ export class AdoPRStrategy extends BasePRStrategy {
168
250
  if (config.mode === "force") {
169
251
  const bypassReason = config.bypassReason ?? "Automated config sync via xfg";
170
252
  this.log?.warn(`Bypassing policies for PR ${prInfo.prId} (reason: ${bypassReason})`);
171
- const forceCommand = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --bypass-policy true --bypass-policy-reason ${escapeShellArg(bypassReason)} --status completed ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}`.trim();
172
- return this.executeMergeCommand(() => this.executor.exec(forceCommand, workDir), retries, {
253
+ const forceArgs = [
254
+ "repos",
255
+ "pr",
256
+ "update",
257
+ "--id",
258
+ prInfo.prId,
259
+ "--bypass-policy",
260
+ "true",
261
+ "--bypass-policy-reason",
262
+ bypassReason,
263
+ "--status",
264
+ "completed",
265
+ ...(config.strategy === "squash" ? ["--squash", "true"] : []),
266
+ ...(config.deleteBranch ? ["--delete-source-branch", "true"] : []),
267
+ "--org",
268
+ orgUrl,
269
+ ];
270
+ return this.executeMergeCommand(() => this.executor.exec("az", forceArgs, workDir), retries, {
173
271
  success: true,
174
272
  message: "PR completed by bypassing policies.",
175
273
  merged: true,
@@ -1,4 +1,3 @@
1
- import { escapeShellArg } from "../shared/shell-utils.js";
2
1
  import { withRetry } from "../shared/retry-utils.js";
3
2
  import { toErrorMessage } from "../shared/type-guards.js";
4
3
  import { SyncError } from "../shared/errors.js";
@@ -17,8 +16,8 @@ export class AuthenticatedGitOps {
17
16
  this.auth = options.auth;
18
17
  this.log = options.log;
19
18
  }
20
- execWithRetry(command) {
21
- return withRetry(() => this.executor.exec(command, this.workDir), {
19
+ execWithRetry(executable, args) {
20
+ return withRetry(() => this.executor.exec(executable, args, this.workDir), {
22
21
  retries: this.retries,
23
22
  });
24
23
  }
@@ -85,28 +84,43 @@ export class AuthenticatedGitOps {
85
84
  return this.localOps.getDefaultBranchLocal();
86
85
  }
87
86
  // --- INetworkGitOps with auth wrapping ---
88
- // Note: exec() usage here is safe — all user inputs are escaped via escapeShellArg()
89
87
  async clone(gitUrl) {
90
88
  if (!this.auth) {
91
- const command = `git clone ${escapeShellArg(gitUrl)} .`;
92
- await this.execWithRetry(command);
89
+ await this.execWithRetry("git", ["clone", "--", gitUrl, "."]);
93
90
  return;
94
91
  }
95
- const authUrl = escapeShellArg(this.getAuthenticatedUrl());
96
- await this.execWithRetry(`git clone ${authUrl} .`);
92
+ await this.execWithRetry("git", [
93
+ "clone",
94
+ "--",
95
+ this.getAuthenticatedUrl(),
96
+ ".",
97
+ ]);
97
98
  }
98
99
  async fetch(options) {
99
- const pruneFlag = options?.prune ? " --prune" : "";
100
- await this.execWithRetry(`git fetch origin${pruneFlag}`);
100
+ await this.execWithRetry("git", [
101
+ "fetch",
102
+ "origin",
103
+ ...(options?.prune ? ["--prune"] : []),
104
+ ]);
101
105
  }
102
106
  async push(branchName, options) {
103
- const forceFlag = options?.force ? "--force-with-lease " : "";
104
- const safeBranch = escapeShellArg(branchName);
105
- await this.execWithRetry(`git push ${forceFlag}-u origin ${safeBranch}`);
107
+ const args = [
108
+ "push",
109
+ ...(options?.force ? ["--force-with-lease"] : []),
110
+ "-u",
111
+ "origin",
112
+ "--",
113
+ branchName,
114
+ ];
115
+ await this.execWithRetry("git", args);
106
116
  }
107
117
  async getDefaultBranch() {
108
118
  try {
109
- const remoteInfo = await this.execWithRetry(`git remote show origin`);
119
+ const remoteInfo = await this.execWithRetry("git", [
120
+ "remote",
121
+ "show",
122
+ "origin",
123
+ ]);
110
124
  const match = remoteInfo.match(/HEAD branch: (\S+)/);
111
125
  if (match && match[1] !== "(unknown)") {
112
126
  return { branch: match[1], method: "remote HEAD" };
@@ -127,28 +141,36 @@ export class AuthenticatedGitOps {
127
141
  * branch existence where failure is expected for new branches.
128
142
  */
129
143
  lsRemote(branchName, options) {
130
- const safeBranch = escapeShellArg(branchName);
131
- const command = `git ls-remote --exit-code --heads origin ${safeBranch}`;
144
+ const args = ["ls-remote", "--exit-code", "--heads", "origin", branchName];
132
145
  if (options?.skipRetry) {
133
- return this.executor.exec(command, this.workDir);
146
+ return this.executor.exec("git", args, this.workDir);
134
147
  }
135
- return this.execWithRetry(command);
148
+ return this.execWithRetry("git", args);
136
149
  }
137
150
  /**
138
151
  * Execute push with custom refspec (e.g., HEAD:branchName).
139
152
  * Used by GraphQLCommitStrategy for creating/deleting remote branches.
140
153
  */
141
154
  async pushRefspec(refspec, options) {
142
- const deleteFlag = options?.delete ? "--delete " : "";
143
- const safeRefspec = escapeShellArg(refspec);
144
- await this.execWithRetry(`git push ${deleteFlag}-u origin ${safeRefspec}`);
155
+ const args = [
156
+ "push",
157
+ ...(options?.delete ? ["--delete"] : []),
158
+ "-u",
159
+ "origin",
160
+ "--",
161
+ refspec,
162
+ ];
163
+ await this.execWithRetry("git", args);
145
164
  }
146
165
  /**
147
166
  * Fetch a specific branch from remote.
148
167
  * Used by GraphQLCommitStrategy to update local refs.
149
168
  */
150
169
  async fetchBranch(branchName) {
151
- const safeBranch = escapeShellArg(branchName);
152
- await this.execWithRetry(`git fetch origin +${safeBranch}:refs/remotes/origin/${safeBranch}`);
170
+ await this.execWithRetry("git", [
171
+ "fetch",
172
+ "origin",
173
+ `+${branchName}:refs/remotes/origin/${branchName}`,
174
+ ]);
153
175
  }
154
176
  }
@@ -1,5 +1,4 @@
1
1
  import { withRetry } from "../shared/retry-utils.js";
2
- import { escapeShellArg } from "../shared/shell-utils.js";
3
2
  /**
4
3
  * Git-based commit strategy using standard git commands (add, commit, push).
5
4
  * Used with PAT authentication. Commits via this strategy are NOT verified
@@ -20,20 +19,25 @@ export class GitCommitStrategy {
20
19
  const { branchName, message, workDir, retries = 3, force = true, gitOps, } = options;
21
20
  // Commit with the message (--no-verify to skip pre-commit hooks)
22
21
  // Staging is handled by CommitPushManager before calling commit()
23
- await this.executor.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, workDir);
22
+ await this.executor.exec("git", ["commit", "--no-verify", "-m", message], workDir);
24
23
  // Push with authentication via gitOps if available
25
24
  if (gitOps) {
26
25
  await gitOps.push(branchName, { force });
27
26
  }
28
27
  else {
29
28
  // Fallback for non-authenticated scenarios (shouldn't happen in practice)
30
- const forceFlag = force ? "--force-with-lease " : "";
31
- const pushCommand = `git push ${forceFlag}-u origin ${escapeShellArg(branchName)}`;
32
- await withRetry(() => this.executor.exec(pushCommand, workDir), {
29
+ const args = [
30
+ "push",
31
+ ...(force ? ["--force-with-lease"] : []),
32
+ "-u",
33
+ "origin",
34
+ branchName,
35
+ ];
36
+ await withRetry(() => this.executor.exec("git", args, workDir), {
33
37
  retries,
34
38
  });
35
39
  }
36
- const sha = await this.executor.exec("git rev-parse HEAD", workDir);
40
+ const sha = await this.executor.exec("git", ["rev-parse", "HEAD"], workDir);
37
41
  return {
38
42
  sha: sha.trim(),
39
43
  verified: false, // Git-based commits are not verified
@@ -1,6 +1,5 @@
1
1
  import { rmSync, existsSync, statSync, mkdirSync, writeFileSync, readFileSync, chmodSync, } from "node:fs";
2
2
  import { join, resolve, relative, isAbsolute, dirname } from "node:path";
3
- import { escapeShellArg } from "../shared/shell-utils.js";
4
3
  import { toErrorMessage } from "../shared/type-guards.js";
5
4
  import { ValidationError, SyncError } from "../shared/errors.js";
6
5
  export class GitOps {
@@ -14,8 +13,8 @@ export class GitOps {
14
13
  this.executor = options.executor;
15
14
  this.log = options.log;
16
15
  }
17
- exec(command, cwd) {
18
- return this.executor.exec(command, cwd ?? this.workDir);
16
+ exec(executable, args, cwd) {
17
+ return this.executor.exec(executable, args, cwd ?? this.workDir);
19
18
  }
20
19
  /**
21
20
  * Validates that a file path doesn't escape the workspace directory.
@@ -40,7 +39,7 @@ export class GitOps {
40
39
  mkdirSync(this.workDir, { recursive: true });
41
40
  }
42
41
  catch (error) {
43
- throw new SyncError(`Failed to clean workspace '${this.workDir}': ${toErrorMessage(error)}`);
42
+ throw new SyncError(`Failed to clean workspace '${this.workDir}': ${toErrorMessage(error)}`, { cause: error });
44
43
  }
45
44
  }
46
45
  /**
@@ -50,11 +49,11 @@ export class GitOps {
50
49
  */
51
50
  async createBranch(branchName) {
52
51
  try {
53
- await this.exec(`git checkout -b ${escapeShellArg(branchName)}`, this.workDir);
52
+ await this.exec("git", ["checkout", "-b", branchName]);
54
53
  }
55
54
  catch (error) {
56
55
  const message = toErrorMessage(error);
57
- throw new SyncError(`Failed to create branch '${branchName}': ${message}`);
56
+ throw new SyncError(`Failed to create branch '${branchName}': ${message}`, { cause: error });
58
57
  }
59
58
  }
60
59
  writeFile(fileName, content) {
@@ -68,7 +67,7 @@ export class GitOps {
68
67
  writeFileSync(filePath, normalized, "utf-8");
69
68
  }
70
69
  catch (error) {
71
- throw new SyncError(`Failed to write file '${fileName}': ${toErrorMessage(error)}`);
70
+ throw new SyncError(`Failed to write file '${fileName}': ${toErrorMessage(error)}`, { cause: error });
72
71
  }
73
72
  }
74
73
  /**
@@ -86,11 +85,16 @@ export class GitOps {
86
85
  chmodSync(filePath, 0o755);
87
86
  }
88
87
  catch (error) {
89
- throw new SyncError(`Failed to set executable permissions on '${fileName}': ${toErrorMessage(error)}`);
88
+ throw new SyncError(`Failed to set executable permissions on '${fileName}': ${toErrorMessage(error)}`, { cause: error });
90
89
  }
91
- // Also update git's index so the executable bit is committed
92
90
  const relativePath = relative(this.workDir, filePath);
93
- await this.exec(`git update-index --add --chmod=+x ${escapeShellArg(relativePath)}`, this.workDir);
91
+ await this.exec("git", [
92
+ "update-index",
93
+ "--add",
94
+ "--chmod=+x",
95
+ "--",
96
+ relativePath,
97
+ ]);
94
98
  }
95
99
  /**
96
100
  * Clears the executable bit on a file both on the filesystem and in git's index.
@@ -105,9 +109,9 @@ export class GitOps {
105
109
  chmodSync(filePath, 0o644);
106
110
  }
107
111
  catch (error) {
108
- throw new SyncError(`Failed to clear executable permissions on '${fileName}': ${toErrorMessage(error)}`);
112
+ throw new SyncError(`Failed to clear executable permissions on '${fileName}': ${toErrorMessage(error)}`, { cause: error });
109
113
  }
110
- await this.exec(`git update-index --chmod=-x -- ${escapeShellArg(fileName)}`, this.workDir);
114
+ await this.exec("git", ["update-index", "--chmod=-x", "--", fileName]);
111
115
  }
112
116
  /**
113
117
  * Returns the git index mode for a tracked file ("100755" or "100644"),
@@ -116,7 +120,7 @@ export class GitOps {
116
120
  */
117
121
  async getFileMode(fileName) {
118
122
  this.validatePath(fileName);
119
- const output = await this.exec(`git ls-files -s -- ${escapeShellArg(fileName)}`, this.workDir);
123
+ const output = await this.exec("git", ["ls-files", "-s", "--", fileName]);
120
124
  const line = output.trim();
121
125
  if (!line)
122
126
  return null;
@@ -171,7 +175,7 @@ export class GitOps {
171
175
  }
172
176
  }
173
177
  async hasChanges() {
174
- const status = await this.exec("git status --porcelain", this.workDir);
178
+ const status = await this.exec("git", ["status", "--porcelain"]);
175
179
  return status.length > 0;
176
180
  }
177
181
  /**
@@ -179,7 +183,7 @@ export class GitOps {
179
183
  * Returns relative file paths for files that are modified, added, or untracked.
180
184
  */
181
185
  async getChangedFiles() {
182
- const status = await this.exec("git status --porcelain", this.workDir);
186
+ const status = await this.exec("git", ["status", "--porcelain"]);
183
187
  if (!status)
184
188
  return [];
185
189
  return status
@@ -188,10 +192,10 @@ export class GitOps {
188
192
  .map((line) => line.slice(3)); // Remove status prefix (e.g., " M ", "?? ", "A ")
189
193
  }
190
194
  async stageAll() {
191
- await this.exec("git add -A", this.workDir);
195
+ await this.exec("git", ["add", "-A"]);
192
196
  }
193
197
  async hasStagedChanges() {
194
- const diff = await this.exec("git diff --cached --name-only", this.workDir);
198
+ const diff = await this.exec("git", ["diff", "--cached", "--name-only"]);
195
199
  return diff.length > 0;
196
200
  }
197
201
  /**
@@ -199,8 +203,11 @@ export class GitOps {
199
203
  * Used for createOnly checks against the base branch (not the working directory).
200
204
  */
201
205
  async fileExistsOnBranch(fileName, branch) {
206
+ if (branch.startsWith("-")) {
207
+ throw new ValidationError(`Branch name '${branch}' is not supported: branch names starting with '-' can be misinterpreted as git flags`);
208
+ }
202
209
  try {
203
- await this.exec(`git show ${escapeShellArg(branch)}:${escapeShellArg(fileName)}`, this.workDir);
210
+ await this.exec("git", ["show", `${branch}:${fileName}`]);
204
211
  return true;
205
212
  }
206
213
  catch (error) {
@@ -236,7 +243,7 @@ export class GitOps {
236
243
  rmSync(filePath);
237
244
  }
238
245
  catch (error) {
239
- throw new SyncError(`Failed to delete file '${fileName}': ${toErrorMessage(error)}`);
246
+ throw new SyncError(`Failed to delete file '${fileName}': ${toErrorMessage(error)}`, { cause: error });
240
247
  }
241
248
  }
242
249
  /**
@@ -248,13 +255,12 @@ export class GitOps {
248
255
  if (this.dryRun) {
249
256
  return true;
250
257
  }
251
- await this.exec("git add -A", this.workDir);
258
+ await this.exec("git", ["add", "-A"]);
252
259
  // Check if there are actually staged changes after git add
253
260
  if (!(await this.hasStagedChanges())) {
254
261
  return false; // No changes to commit
255
262
  }
256
- // Use --no-verify to skip pre-commit hooks
257
- await this.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, this.workDir);
263
+ await this.exec("git", ["commit", "--no-verify", "-m", message]);
258
264
  return true;
259
265
  }
260
266
  /**
@@ -263,7 +269,7 @@ export class GitOps {
263
269
  */
264
270
  async getDefaultBranchLocal() {
265
271
  try {
266
- await this.exec("git rev-parse --verify origin/main", this.workDir);
272
+ await this.exec("git", ["rev-parse", "--verify", "origin/main"]);
267
273
  return { branch: "main", method: "origin/main exists" };
268
274
  }
269
275
  catch (error) {
@@ -271,7 +277,7 @@ export class GitOps {
271
277
  this.log?.debug(`origin/main check failed - ${msg}`);
272
278
  }
273
279
  try {
274
- await this.exec("git rev-parse --verify origin/master", this.workDir);
280
+ await this.exec("git", ["rev-parse", "--verify", "origin/master"]);
275
281
  return { branch: "master", method: "origin/master exists" };
276
282
  }
277
283
  catch (error) {
@@ -1,9 +1,10 @@
1
1
  import type { PRResult } from "./types.js";
2
2
  import { BasePRStrategy } from "./pr-strategy.js";
3
- import type { PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./types.js";
3
+ import type { PRStrategyOptions, CloseExistingPROptions, ClosePRResult, MergeOptions, MergeResult } from "./types.js";
4
4
  export declare class GitHubPRStrategy extends BasePRStrategy {
5
+ private readonly bodyFilePath;
5
6
  findExistingPRUrl(options: CloseExistingPROptions): Promise<string | null>;
6
- closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
7
+ closeExistingPR(options: CloseExistingPROptions): Promise<ClosePRResult>;
7
8
  create(options: PRStrategyOptions): Promise<PRResult>;
8
9
  /**
9
10
  * Check if auto-merge is enabled on the repository.