@aspruyt/xfg 3.10.0 → 3.10.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.
@@ -1,6 +1,7 @@
1
1
  import { FileStatus } from "../sync/diff-utils.js";
2
2
  export interface ILogger {
3
3
  info(message: string): void;
4
+ warn(message: string): void;
4
5
  debug(message: string): void;
5
6
  fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
6
7
  diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
@@ -21,6 +22,7 @@ export declare class Logger implements ILogger {
21
22
  setTotal(total: number): void;
22
23
  progress(current: number, repoName: string, message: string): void;
23
24
  info(message: string): void;
25
+ warn(message: string): void;
24
26
  debug(message: string): void;
25
27
  success(current: number, repoName: string, message: string): void;
26
28
  skip(current: number, repoName: string, reason: string): void;
@@ -17,6 +17,9 @@ export class Logger {
17
17
  info(message) {
18
18
  console.log(chalk.gray(` ${message}`));
19
19
  }
20
+ warn(message) {
21
+ console.log(chalk.yellow(` ⚠ ${message}`));
22
+ }
20
23
  debug(message) {
21
24
  if (process.env.DEBUG || process.env.XFG_DEBUG) {
22
25
  console.log(chalk.dim(` [debug] ${message}`));
@@ -8,6 +8,7 @@ import { sanitizeCredentials } from "./sanitize-utils.js";
8
8
  */
9
9
  export const DEFAULT_PERMANENT_ERROR_PATTERNS = [
10
10
  /permission\s*denied/i,
11
+ /not\s*accessible\s*by\s*integration/i,
11
12
  /authentication\s*failed/i,
12
13
  /bad\s*credentials/i,
13
14
  /invalid\s*(token|credentials)/i,
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { convertContentToString } from "../config/formatter.js";
4
4
  import { interpolateXfgContent } from "./xfg-template.js";
5
5
  import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
6
+ import { hasGitHubAppCredentials } from "../vcs/commit-strategy-selector.js";
6
7
  /**
7
8
  * Determines if a file should be marked as executable.
8
9
  * .sh files are auto-executable unless explicit executable: false is set.
@@ -92,6 +93,11 @@ export class FileWriter {
92
93
  continue;
93
94
  }
94
95
  if (shouldBeExecutable(file)) {
96
+ if (tracked?.action === "create" && hasGitHubAppCredentials()) {
97
+ log.warn(`${file.fileName}: GitHub App commits cannot set executable mode on new files. ` +
98
+ `The file will be created as non-executable (100644). ` +
99
+ `See: https://anthony-spruyt.github.io/xfg/examples/executable-files/`);
100
+ }
95
101
  log.info(`Setting executable: ${file.fileName}`);
96
102
  await gitOps.setExecutable(file.fileName);
97
103
  }
@@ -30,6 +30,13 @@ export declare function validateBranchName(branchName: string): void;
30
30
  * This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
31
31
  */
32
32
  export declare class GraphQLCommitStrategy implements ICommitStrategy {
33
+ /**
34
+ * GraphQL permanent error patterns for ref operations.
35
+ * Differs from DEFAULT_PERMANENT_ERROR_PATTERNS which has
36
+ * git-CLI-specific patterns (/remote\s*rejected/i) that don't
37
+ * apply to GraphQL responses.
38
+ */
39
+ private static readonly GRAPHQL_PERMANENT_ERROR_PATTERNS;
33
40
  private executor;
34
41
  constructor(executor?: ICommandExecutor);
35
42
  /**
@@ -48,6 +55,9 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
48
55
  * Ensure the branch exists on the remote and matches local HEAD.
49
56
  * createCommitOnBranch requires the branch to already exist.
50
57
  *
58
+ * Uses GraphQL ref mutations instead of git push to support repos
59
+ * with required_signatures on all branches.
60
+ *
51
61
  * For PR branches (force=true): delete existing remote branch and recreate
52
62
  * from local HEAD to ensure a fresh start from main.
53
63
  *
@@ -66,4 +76,23 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
66
76
  * This happens when the branch was updated between getting HEAD and making the commit.
67
77
  */
68
78
  private isHeadOidMismatchError;
79
+ /**
80
+ * Execute a GraphQL query or mutation for ref operations.
81
+ * Handles command construction, retry, error sanitization, and response parsing.
82
+ * Uses gh CLI's --input flag to pass GraphQL via stdin (same pattern as executeGraphQLMutation).
83
+ */
84
+ private executeGraphQLRefOp;
85
+ /**
86
+ * Query the remote for a repository's Node ID and a ref's Node ID.
87
+ * Returns repositoryId (always) and refId (null if branch doesn't exist).
88
+ */
89
+ private queryRemoteRef;
90
+ /**
91
+ * Create a branch ref on the remote via GraphQL createRef mutation.
92
+ */
93
+ private createRemoteRef;
94
+ /**
95
+ * Delete a branch ref on the remote via GraphQL deleteRef mutation.
96
+ */
97
+ private deleteRemoteRef;
69
98
  }
@@ -48,6 +48,24 @@ const OID_MISMATCH_PATTERNS = [
48
48
  * This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
49
49
  */
50
50
  export class GraphQLCommitStrategy {
51
+ /**
52
+ * GraphQL permanent error patterns for ref operations.
53
+ * Differs from DEFAULT_PERMANENT_ERROR_PATTERNS which has
54
+ * git-CLI-specific patterns (/remote\s*rejected/i) that don't
55
+ * apply to GraphQL responses.
56
+ */
57
+ static GRAPHQL_PERMANENT_ERROR_PATTERNS = [
58
+ /not\s*found/i,
59
+ /unauthorized/i,
60
+ /permission\s*denied/i,
61
+ /not\s*accessible\s*by\s*integration/i,
62
+ /bad\s*credentials/i,
63
+ /invalid\s*(token|credentials)/i,
64
+ /401\b/,
65
+ /403\b/,
66
+ /does\s*not\s*exist/i,
67
+ /could\s*not\s*resolve/i,
68
+ ];
51
69
  executor;
52
70
  constructor(executor) {
53
71
  this.executor = executor ?? defaultExecutor;
@@ -85,7 +103,7 @@ export class GraphQLCommitStrategy {
85
103
  // Ensure the branch exists on remote and is up-to-date with local HEAD
86
104
  // createCommitOnBranch requires the branch to already exist
87
105
  // For PR branches (force=true), we force-update to ensure fresh start from main
88
- await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, gitOps);
106
+ await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, githubInfo, token);
89
107
  // Retry loop for expectedHeadOid mismatch
90
108
  let lastError = null;
91
109
  for (let attempt = 0; attempt <= retries; attempt++) {
@@ -204,47 +222,31 @@ export class GraphQLCommitStrategy {
204
222
  * Ensure the branch exists on the remote and matches local HEAD.
205
223
  * createCommitOnBranch requires the branch to already exist.
206
224
  *
225
+ * Uses GraphQL ref mutations instead of git push to support repos
226
+ * with required_signatures on all branches.
227
+ *
207
228
  * For PR branches (force=true): delete existing remote branch and recreate
208
229
  * from local HEAD to ensure a fresh start from main.
209
230
  *
210
231
  * For direct mode (force=false): just ensure branch exists.
211
232
  */
212
- async ensureBranchExistsOnRemote(branchName, workDir, force, gitOps) {
213
- // Branch name was validated in commit(), safe for shell use
214
- try {
215
- // Check if the branch exists on remote
216
- // Use skipRetry because failure is expected for new branches
217
- if (gitOps) {
218
- await gitOps.lsRemote(branchName, { skipRetry: true });
219
- }
220
- else {
221
- await this.executor.exec(`git ls-remote --exit-code --heads origin ${escapeShellArg(branchName)}`, workDir);
222
- }
223
- // Branch exists - for PR branches, delete and recreate to ensure fresh from main
224
- if (force) {
225
- if (gitOps) {
226
- await gitOps.pushRefspec(branchName, { delete: true });
227
- // Now push fresh branch from local HEAD
228
- await gitOps.pushRefspec(`HEAD:${branchName}`);
229
- }
230
- else {
231
- await this.executor.exec(`git push origin --delete ${escapeShellArg(branchName)}`, workDir);
232
- // Now push fresh branch from local HEAD
233
- await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
234
- }
235
- }
236
- // For direct mode (force=false), leave existing branch as-is
233
+ async ensureBranchExistsOnRemote(branchName, workDir, force, repoInfo, token) {
234
+ if (!repoInfo) {
235
+ throw new Error("repoInfo is required for GraphQL ref operations");
237
236
  }
238
- catch {
239
- // Branch doesn't exist on remote, push it
240
- // This pushes the current local branch to create it on remote
241
- if (gitOps) {
242
- await gitOps.pushRefspec(`HEAD:${branchName}`);
243
- }
244
- else {
245
- await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
246
- }
237
+ const { repositoryId, refId } = await this.queryRemoteRef(repoInfo, branchName, workDir, token);
238
+ if (refId && force) {
239
+ // Branch exists + force: delete then recreate from local HEAD
240
+ await this.deleteRemoteRef(refId, workDir, repoInfo, token);
241
+ const sha = (await this.executor.exec("git rev-parse HEAD", workDir)).trim();
242
+ await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
243
+ }
244
+ else if (!refId) {
245
+ // Branch doesn't exist: create from local HEAD
246
+ const sha = (await this.executor.exec("git rev-parse HEAD", workDir)).trim();
247
+ await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
247
248
  }
249
+ // refId exists + !force: no-op (branch already exists)
248
250
  }
249
251
  /**
250
252
  * Sanitize command execution errors to remove the GraphQL payload.
@@ -284,4 +286,59 @@ export class GraphQLCommitStrategy {
284
286
  // GitHub may return this generic error for OID mismatches
285
287
  message.includes("was provided invalid value"));
286
288
  }
289
+ /**
290
+ * Execute a GraphQL query or mutation for ref operations.
291
+ * Handles command construction, retry, error sanitization, and response parsing.
292
+ * Uses gh CLI's --input flag to pass GraphQL via stdin (same pattern as executeGraphQLMutation).
293
+ */
294
+ async executeGraphQLRefOp(queryOrMutation, repoInfo, workDir, token) {
295
+ const requestBody = JSON.stringify({ query: queryOrMutation });
296
+ const hostnameArg = repoInfo.host !== "github.com"
297
+ ? `--hostname ${escapeShellArg(repoInfo.host)}`
298
+ : "";
299
+ const tokenPrefix = token ? `GH_TOKEN=${token} ` : "";
300
+ const command = `echo ${escapeShellArg(requestBody)} | ${tokenPrefix}gh api graphql ${hostnameArg} --input -`;
301
+ let response;
302
+ try {
303
+ response = await withRetry(() => this.executor.exec(command, workDir), {
304
+ permanentErrorPatterns: GraphQLCommitStrategy.GRAPHQL_PERMANENT_ERROR_PATTERNS,
305
+ });
306
+ }
307
+ catch (error) {
308
+ throw this.sanitizeCommandError(error, `${repoInfo.owner}/${repoInfo.repo}`);
309
+ }
310
+ const parsed = JSON.parse(response);
311
+ if (parsed.errors) {
312
+ throw new Error(`GraphQL error: ${parsed.errors.map((e) => e.message).join(", ")}`);
313
+ }
314
+ return parsed;
315
+ }
316
+ /**
317
+ * Query the remote for a repository's Node ID and a ref's Node ID.
318
+ * Returns repositoryId (always) and refId (null if branch doesn't exist).
319
+ */
320
+ async queryRemoteRef(repoInfo, branchName, workDir, token) {
321
+ const query = `{ repository(owner: ${JSON.stringify(repoInfo.owner)}, name: ${JSON.stringify(repoInfo.repo)}) { id ref(qualifiedName: ${JSON.stringify(`refs/heads/${branchName}`)}) { id } } }`;
322
+ const parsed = await this.executeGraphQLRefOp(query, repoInfo, workDir, token);
323
+ const repo = parsed.data?.repository;
324
+ const repositoryId = repo?.id;
325
+ if (!repositoryId) {
326
+ throw new Error(`GraphQL response missing repository ID for ${repoInfo.owner}/${repoInfo.repo}`);
327
+ }
328
+ return { repositoryId, refId: repo?.ref?.id ?? null };
329
+ }
330
+ /**
331
+ * Create a branch ref on the remote via GraphQL createRef mutation.
332
+ */
333
+ async createRemoteRef(repositoryId, branchName, oid, workDir, repoInfo, token) {
334
+ const mutation = `mutation { createRef(input: { repositoryId: ${JSON.stringify(repositoryId)}, name: ${JSON.stringify(`refs/heads/${branchName}`)}, oid: ${JSON.stringify(oid)} }) { clientMutationId } }`;
335
+ await this.executeGraphQLRefOp(mutation, repoInfo, workDir, token);
336
+ }
337
+ /**
338
+ * Delete a branch ref on the remote via GraphQL deleteRef mutation.
339
+ */
340
+ async deleteRemoteRef(refId, workDir, repoInfo, token) {
341
+ const mutation = `mutation { deleteRef(input: { refId: ${JSON.stringify(refId)} }) { clientMutationId } }`;
342
+ await this.executeGraphQLRefOp(mutation, repoInfo, workDir, token);
343
+ }
287
344
  }
@@ -96,7 +96,7 @@ export interface CommitOptions {
96
96
  force?: boolean;
97
97
  /** GitHub App installation token for authentication (used by GraphQLCommitStrategy) */
98
98
  token?: string;
99
- /** Authenticated git operations wrapper (used by GraphQLCommitStrategy for network ops) */
99
+ /** Authenticated git operations wrapper (used by GraphQLCommitStrategy for fetchBranch() during OID mismatch retries) */
100
100
  gitOps?: IAuthenticatedGitOps;
101
101
  }
102
102
  export interface CommitResult {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.10.0",
3
+ "version": "3.10.1",
4
4
  "description": "Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab — declaratively, from a single YAML config",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",