@aspruyt/xfg 5.5.0 → 5.7.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.
@@ -394,16 +394,18 @@ function mergeGroupSettings(rootSettings, groupNames, groupDefs) {
394
394
  }
395
395
  /**
396
396
  * Evaluates a conditional group's `when` clause against a repo's effective groups.
397
- * Both `allOf` (every listed group present) and `anyOf` (at least one present)
398
- * must be satisfied. Absent conditions are treated as satisfied.
397
+ * All specified operators must be satisfied: `allOf` (every listed group present),
398
+ * `anyOf` (at least one present), and `noneOf` (none of the listed groups present).
399
+ * Absent conditions are treated as satisfied.
399
400
  */
400
401
  function evaluateWhenClause(when, effectiveGroups) {
401
- // Defensive: if neither condition is specified, don't match
402
- if (!when.allOf && !when.anyOf)
402
+ // Defensive: if no condition is specified, don't match
403
+ if (!when.allOf && !when.anyOf && !when.noneOf)
403
404
  return false;
404
405
  const allOfSatisfied = !when.allOf || when.allOf.every((g) => effectiveGroups.has(g));
405
406
  const anyOfSatisfied = !when.anyOf || when.anyOf.some((g) => effectiveGroups.has(g));
406
- return allOfSatisfied && anyOfSatisfied;
407
+ const noneOfSatisfied = !when.noneOf || when.noneOf.every((g) => !effectiveGroups.has(g));
408
+ return allOfSatisfied && anyOfSatisfied && noneOfSatisfied;
407
409
  }
408
410
  /**
409
411
  * Merges matching conditional groups into the accumulated files/prOptions/settings.
@@ -315,6 +315,8 @@ export interface RawConditionalGroupWhen {
315
315
  allOf?: string[];
316
316
  /** At least one listed group must be present */
317
317
  anyOf?: string[];
318
+ /** None of the listed groups may be present */
319
+ noneOf?: string[];
318
320
  }
319
321
  /** Conditional group: activates based on which groups a repo has */
320
322
  export interface RawConditionalGroupConfig {
@@ -449,9 +449,9 @@ function validateConditionalGroups(config) {
449
449
  if (!entry.when || !isPlainObject(entry.when)) {
450
450
  throw new ValidationError(`${ctx}: 'when' is required and must be an object`);
451
451
  }
452
- const { allOf, anyOf } = entry.when;
453
- if (!allOf && !anyOf) {
454
- throw new ValidationError(`${ctx}: 'when' must have at least one of 'allOf' or 'anyOf'`);
452
+ const { allOf, anyOf, noneOf } = entry.when;
453
+ if (!allOf && !anyOf && !noneOf) {
454
+ throw new ValidationError(`${ctx}: 'when' must have at least one of 'allOf', 'anyOf', or 'noneOf'`);
455
455
  }
456
456
  if (allOf !== undefined) {
457
457
  validateGroupRefArray(allOf, "allOf", ctx, groupNames);
@@ -459,6 +459,27 @@ function validateConditionalGroups(config) {
459
459
  if (anyOf !== undefined) {
460
460
  validateGroupRefArray(anyOf, "anyOf", ctx, groupNames);
461
461
  }
462
+ if (noneOf !== undefined) {
463
+ validateGroupRefArray(noneOf, "noneOf", ctx, groupNames);
464
+ }
465
+ // Cross-operator overlap: noneOf must not share groups with allOf or anyOf
466
+ if (noneOf) {
467
+ const noneOfSet = new Set(noneOf);
468
+ if (allOf) {
469
+ for (const g of allOf) {
470
+ if (noneOfSet.has(g)) {
471
+ throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with allOf (contradictory condition)`);
472
+ }
473
+ }
474
+ }
475
+ if (anyOf) {
476
+ for (const g of anyOf) {
477
+ if (noneOfSet.has(g)) {
478
+ throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with anyOf (contradictory condition)`);
479
+ }
480
+ }
481
+ }
482
+ }
462
483
  // Validate files
463
484
  if (entry.files) {
464
485
  for (const [fileName, fileConfig] of Object.entries(entry.files)) {
@@ -15,6 +15,7 @@ export declare class GitHubRepoSettingsStrategy implements IRepoSettingsStrategy
15
15
  setVulnerabilityAlerts(repoInfo: RepoInfo, enable: boolean, options?: GhApiOptions): Promise<void>;
16
16
  setAutomatedSecurityFixes(repoInfo: RepoInfo, enable: boolean, options?: GhApiOptions): Promise<void>;
17
17
  setPrivateVulnerabilityReporting(repoInfo: RepoInfo, enable: boolean, options?: GhApiOptions): Promise<void>;
18
+ branchExists(repoInfo: RepoInfo, branch: string, options?: GhApiOptions): Promise<boolean>;
18
19
  private getVulnerabilityAlerts;
19
20
  private getAutomatedSecurityFixes;
20
21
  private getPrivateVulnerabilityReporting;
@@ -104,6 +104,20 @@ export class GitHubRepoSettingsStrategy {
104
104
  const method = enable ? "PUT" : "DELETE";
105
105
  await this.api.call(method, endpoint, { options });
106
106
  }
107
+ async branchExists(repoInfo, branch, options) {
108
+ assertGitHubRepo(repoInfo, "GitHub Repo Settings strategy");
109
+ const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/branches/${encodeURIComponent(branch)}`;
110
+ try {
111
+ await this.api.call("GET", endpoint, { options });
112
+ return true;
113
+ }
114
+ catch (error) {
115
+ if (isHttp404Error(error)) {
116
+ return false;
117
+ }
118
+ throw error;
119
+ }
120
+ }
107
121
  async getVulnerabilityAlerts(github, options) {
108
122
  const endpoint = `/repos/${github.owner}/${github.repo}/vulnerability-alerts`;
109
123
  try {
@@ -47,6 +47,22 @@ export class RepoSettingsProcessor {
47
47
  changes: { create: 0, update: 0, delete: 0, unchanged: unchangedCount },
48
48
  };
49
49
  }
50
+ // Validate defaultBranch target exists before attempting to apply
51
+ const defaultBranchChange = changes.find((c) => c.property === "defaultBranch" && c.action !== "unchanged");
52
+ if (defaultBranchChange) {
53
+ const targetBranch = String(defaultBranchChange.newValue);
54
+ const exists = await this.strategy.branchExists(githubRepo, targetBranch, strategyOptions);
55
+ if (!exists) {
56
+ const currentBranch = defaultBranchChange.oldValue
57
+ ? String(defaultBranchChange.oldValue)
58
+ : "unknown";
59
+ return {
60
+ success: false,
61
+ repoName,
62
+ message: `Failed: Cannot set default branch to '${targetBranch}': branch '${targetBranch}' does not exist (current: '${currentBranch}'). Create or rename the branch first.`,
63
+ };
64
+ }
65
+ }
50
66
  // Format plan output
51
67
  const planOutput = formatRepoSettingsPlan(changes);
52
68
  const changeCounts = {
@@ -63,4 +63,8 @@ export interface IRepoSettingsStrategy {
63
63
  * Enables or disables private vulnerability reporting.
64
64
  */
65
65
  setPrivateVulnerabilityReporting(repoInfo: RepoInfo, enable: boolean, options?: GhApiOptions): Promise<void>;
66
+ /**
67
+ * Checks whether a branch exists in the repository.
68
+ */
69
+ branchExists(repoInfo: RepoInfo, branch: string, options?: GhApiOptions): Promise<boolean>;
66
70
  }
@@ -36,6 +36,15 @@ export declare function parseResponseBody(raw: string): string;
36
36
  * No-op if stdout is absent or does not contain a numeric Retry-After header.
37
37
  */
38
38
  export declare function attachRetryAfter(error: unknown): void;
39
+ /**
40
+ * Extracts GitHub API validation error details from `gh api --include` stdout
41
+ * and appends them to the error message. This surfaces the descriptive error
42
+ * messages that GitHub returns in 422 responses (e.g., "The branch main was
43
+ * not found") which are otherwise lost when only stderr is shown.
44
+ *
45
+ * No-op if stdout is absent or does not contain parseable error JSON.
46
+ */
47
+ export declare function attachValidationDetails(error: unknown): void;
39
48
  /**
40
49
  * Encapsulates executor + retries for GitHub API calls.
41
50
  * Strategies compose with this instead of duplicating ghApi wrappers.
@@ -48,6 +48,42 @@ export function attachRetryAfter(error) {
48
48
  error.retryAfter = parseInt(match[1], 10);
49
49
  }
50
50
  }
51
+ /**
52
+ * Extracts GitHub API validation error details from `gh api --include` stdout
53
+ * and appends them to the error message. This surfaces the descriptive error
54
+ * messages that GitHub returns in 422 responses (e.g., "The branch main was
55
+ * not found") which are otherwise lost when only stderr is shown.
56
+ *
57
+ * No-op if stdout is absent or does not contain parseable error JSON.
58
+ */
59
+ export function attachValidationDetails(error) {
60
+ const stdout = error.stdout;
61
+ if (!stdout)
62
+ return;
63
+ const stdoutStr = typeof stdout === "string" ? stdout : stdout.toString();
64
+ const body = parseResponseBody(stdoutStr);
65
+ try {
66
+ const parsed = JSON.parse(body);
67
+ const details = [];
68
+ if (parsed.errors && parsed.errors.length > 0) {
69
+ for (const err of parsed.errors) {
70
+ if (err.message) {
71
+ const fieldPrefix = err.field ? `${err.field}: ` : "";
72
+ details.push(`${fieldPrefix}${err.message}`);
73
+ }
74
+ }
75
+ }
76
+ else if (parsed.message) {
77
+ details.push(parsed.message);
78
+ }
79
+ if (details.length > 0 && error instanceof Error) {
80
+ error.message += ` [${details.join("; ")}]`;
81
+ }
82
+ }
83
+ catch {
84
+ // JSON parse failed — stdout is not a JSON error response
85
+ }
86
+ }
51
87
  /**
52
88
  * Executes a GitHub API call using the gh CLI.
53
89
  * Shared by labels, rulesets, and repo-settings strategies.
@@ -81,6 +117,7 @@ async function ghApiCall(method, endpoint, opts) {
81
117
  catch (error) {
82
118
  if (!paginate) {
83
119
  attachRetryAfter(error);
120
+ attachValidationDetails(error);
84
121
  }
85
122
  throw error;
86
123
  }
@@ -15,6 +15,7 @@ export const CORE_PERMANENT_ERROR_PATTERNS = [
15
15
  /401\b/,
16
16
  /403\b/,
17
17
  /404\b/,
18
+ /422\b/,
18
19
  /not\s*found/i,
19
20
  /does\s*not\s*exist/i,
20
21
  /repository\s*not\s*found/i,
@@ -18,7 +18,11 @@ export class CommitPushManager {
18
18
  }
19
19
  const changes = Array.from(fileChanges.entries())
20
20
  .filter(([, info]) => info.action !== "skip")
21
- .map(([path, info]) => ({ path, content: info.content }));
21
+ .map(([path, info]) => ({
22
+ path,
23
+ content: info.content,
24
+ ...(info.mode ? { mode: info.mode } : {}),
25
+ }));
22
26
  this.log.info("Staging changes...");
23
27
  await gitOps.stageAll();
24
28
  if (!(await gitOps.hasStagedChanges())) {
@@ -69,6 +69,9 @@ export class FileWriter {
69
69
  fileName: file.fileName,
70
70
  content: fileContent,
71
71
  action,
72
+ // mode is only set on changed files — unchanged files won't trigger a
73
+ // fixup commit, which is correct since their mode was set on a prior sync
74
+ ...(shouldBeExecutable(file) ? { mode: "100755" } : {}),
72
75
  };
73
76
  // Compute raw diff lines for text files (all modes)
74
77
  if (!isBinaryFile(file.fileName)) {
@@ -95,11 +98,6 @@ export class FileWriter {
95
98
  continue;
96
99
  }
97
100
  if (shouldBeExecutable(file)) {
98
- if (tracked?.action === "create" && ctx.hasAppCredentials) {
99
- log.warn(`${file.fileName}: GitHub App commits cannot set executable mode on new files. ` +
100
- `The file will be created as non-executable (100644). ` +
101
- `See: https://anthony-spruyt.github.io/xfg/examples/executable-files/`);
102
- }
103
101
  log.info(`Setting executable: ${file.fileName}`);
104
102
  await gitOps.setExecutable(file.fileName);
105
103
  }
@@ -11,6 +11,9 @@ export interface FileWriteResult {
11
11
  content: string | null;
12
12
  action: "create" | "update" | "delete" | "skip";
13
13
  diffLines?: string[];
14
+ /** Git file mode. Only set for executable files ("100755"). "100644" is included
15
+ * in the union for type completeness — non-executable files omit this field. */
16
+ mode?: "100755" | "100644";
14
17
  }
15
18
  export interface FileWriteContext {
16
19
  repoInfo: RepoInfo;
@@ -19,7 +22,7 @@ export interface FileWriteContext {
19
22
  dryRun: boolean;
20
23
  noDelete: boolean;
21
24
  configId: string;
22
- /** True when using GraphQL commit strategy (GitHub App) which cannot set file modes */
25
+ /** True when using GraphQL commit strategy (GitHub App) */
23
26
  hasAppCredentials?: boolean;
24
27
  }
25
28
  export interface FileWriterDeps {
@@ -129,7 +132,7 @@ export interface ProcessorOptions {
129
132
  noDelete?: boolean;
130
133
  /** GitHub token for authentication (resolved by caller) */
131
134
  token?: string;
132
- /** True when using GraphQL commit strategy (GitHub App) which cannot set file modes */
135
+ /** True when using GraphQL commit strategy (GitHub App) */
133
136
  hasAppCredentials?: boolean;
134
137
  }
135
138
  export interface FileChangeDetail {
@@ -11,8 +11,9 @@ interface GitHubAppCredentials {
11
11
  */
12
12
  export declare function createTokenManager(credentials?: GitHubAppCredentials): GitHubAppTokenManager | null;
13
13
  /**
14
- * Returns GraphQLCommitStrategy for GitHub repos with App credentials (verified commits),
15
- * or GitCommitStrategy for all other cases.
14
+ * Returns FileModeFixupCommitStrategy (decorating GraphQLCommitStrategy) for
15
+ * GitHub repos with App credentials (verified commits + executable file mode
16
+ * support), or GitCommitStrategy for all other cases.
16
17
  */
17
18
  export declare function createCommitStrategy(repoInfo: RepoInfo, executor: ICommandExecutor, hasAppCredentials?: boolean): ICommitStrategy;
18
19
  export {};
@@ -1,6 +1,7 @@
1
1
  import { isGitHubRepo } from "../shared/repo-detector.js";
2
2
  import { GitCommitStrategy } from "./git-commit-strategy.js";
3
3
  import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
4
+ import { FileModeFixupCommitStrategy } from "./file-mode-fixup-commit-strategy.js";
4
5
  import { GitHubAppTokenManager } from "./github-app-token-manager.js";
5
6
  /**
6
7
  * Creates a GitHubAppTokenManager from credentials, or null if not provided.
@@ -12,12 +13,14 @@ export function createTokenManager(credentials) {
12
13
  return new GitHubAppTokenManager(credentials.appId, credentials.privateKey);
13
14
  }
14
15
  /**
15
- * Returns GraphQLCommitStrategy for GitHub repos with App credentials (verified commits),
16
- * or GitCommitStrategy for all other cases.
16
+ * Returns FileModeFixupCommitStrategy (decorating GraphQLCommitStrategy) for
17
+ * GitHub repos with App credentials (verified commits + executable file mode
18
+ * support), or GitCommitStrategy for all other cases.
17
19
  */
18
20
  export function createCommitStrategy(repoInfo, executor, hasAppCredentials) {
19
21
  if (isGitHubRepo(repoInfo) && hasAppCredentials) {
20
- return new GraphQLCommitStrategy(executor);
22
+ const inner = new GraphQLCommitStrategy(executor);
23
+ return new FileModeFixupCommitStrategy(inner, executor);
21
24
  }
22
25
  return new GitCommitStrategy(executor);
23
26
  }
@@ -0,0 +1,34 @@
1
+ import type { ICommitStrategy, CommitOptions, CommitResult } from "./types.js";
2
+ import type { ICommandExecutor } from "../shared/command-executor.js";
3
+ import { GhApiClient } from "../shared/gh-api-utils.js";
4
+ /** Factory type for GhApiClient — enables test injection. */
5
+ export type GhApiClientFactory = (executor: ICommandExecutor, retries: number, cwd: string) => GhApiClient;
6
+ /**
7
+ * Decorator that adds a follow-up commit to fix executable file modes.
8
+ *
9
+ * The GitHub GraphQL createCommitOnBranch mutation cannot set file modes.
10
+ * After the inner strategy (GraphQLCommitStrategy) creates the content commit,
11
+ * this decorator creates a second commit via the REST Git Data API that
12
+ * patches tree modes from 100644 to 100755 for executable files.
13
+ *
14
+ * Only activates when fileChanges contain entries with mode "100755".
15
+ * When no executable files are present, delegates directly to the inner strategy.
16
+ */
17
+ export declare class FileModeFixupCommitStrategy implements ICommitStrategy {
18
+ private readonly inner;
19
+ private readonly executor;
20
+ private readonly clientFactory;
21
+ constructor(inner: ICommitStrategy, executor: ICommandExecutor, clientFactory?: GhApiClientFactory);
22
+ commit(options: CommitOptions): Promise<CommitResult>;
23
+ /**
24
+ * Create a fixup commit that changes file modes from 100644 to 100755.
25
+ *
26
+ * Flow:
27
+ * 1. GET the content commit to find its tree SHA
28
+ * 2. GET the tree (recursive) to find blob SHAs for executable files
29
+ * 3. POST a new tree with updated modes (base_tree carries forward unchanged)
30
+ * 4. POST a new commit with the new tree
31
+ * 5. PATCH the branch ref to point to the new commit
32
+ */
33
+ private createFixupCommit;
34
+ }
@@ -0,0 +1,127 @@
1
+ import { isGitHubRepo } from "../shared/repo-detector.js";
2
+ import { GhApiClient } from "../shared/gh-api-utils.js";
3
+ import { parseApiJson } from "../shared/json-utils.js";
4
+ import { SyncError } from "../shared/errors.js";
5
+ const defaultClientFactory = (executor, retries, cwd) => new GhApiClient(executor, retries, cwd);
6
+ /**
7
+ * Decorator that adds a follow-up commit to fix executable file modes.
8
+ *
9
+ * The GitHub GraphQL createCommitOnBranch mutation cannot set file modes.
10
+ * After the inner strategy (GraphQLCommitStrategy) creates the content commit,
11
+ * this decorator creates a second commit via the REST Git Data API that
12
+ * patches tree modes from 100644 to 100755 for executable files.
13
+ *
14
+ * Only activates when fileChanges contain entries with mode "100755".
15
+ * When no executable files are present, delegates directly to the inner strategy.
16
+ */
17
+ export class FileModeFixupCommitStrategy {
18
+ inner;
19
+ executor;
20
+ clientFactory;
21
+ constructor(inner, executor, clientFactory = defaultClientFactory) {
22
+ this.inner = inner;
23
+ this.executor = executor;
24
+ this.clientFactory = clientFactory;
25
+ }
26
+ async commit(options) {
27
+ const innerResult = await this.inner.commit(options);
28
+ // Only non-deleted files can have their mode fixed (deletions have content === null)
29
+ const executableFiles = options.fileChanges.filter((fc) => fc.mode === "100755" && fc.content !== null);
30
+ if (executableFiles.length === 0) {
31
+ return innerResult;
32
+ }
33
+ // Safety net: only GitHub repos use the REST Git Data API for fixup.
34
+ // Currently only composed for GitHub repos in createCommitStrategy(),
35
+ // but guard defensively in case the decorator is reused elsewhere.
36
+ if (!isGitHubRepo(options.repoInfo)) {
37
+ return innerResult;
38
+ }
39
+ return await this.createFixupCommit(options.repoInfo, options.branchName, innerResult, executableFiles, options.workDir, options.retries ?? 3, options.token);
40
+ }
41
+ /**
42
+ * Create a fixup commit that changes file modes from 100644 to 100755.
43
+ *
44
+ * Flow:
45
+ * 1. GET the content commit to find its tree SHA
46
+ * 2. GET the tree (recursive) to find blob SHAs for executable files
47
+ * 3. POST a new tree with updated modes (base_tree carries forward unchanged)
48
+ * 4. POST a new commit with the new tree
49
+ * 5. PATCH the branch ref to point to the new commit
50
+ */
51
+ async createFixupCommit(repoInfo, branchName, innerResult, executableFiles, workDir, retries, token) {
52
+ const parentSha = innerResult.sha;
53
+ const client = this.clientFactory(this.executor, retries, workDir);
54
+ const apiOpts = {
55
+ token,
56
+ host: repoInfo.host,
57
+ };
58
+ const repoPath = `repos/${repoInfo.owner}/${repoInfo.repo}`;
59
+ // 1. Get the commit to find tree SHA
60
+ const commitRaw = await client.call("GET", `${repoPath}/git/commits/${parentSha}`, { options: apiOpts });
61
+ const commitData = parseApiJson(commitRaw, "GET git commit");
62
+ const treeSha = commitData.tree.sha;
63
+ // 2. Get tree entries to find blob SHAs
64
+ const treeRaw = await client.call("GET", `${repoPath}/git/trees/${treeSha}?recursive=1`, { options: apiOpts });
65
+ const treeData = parseApiJson(treeRaw, "GET git tree");
66
+ const executablePaths = new Set(executableFiles.map((f) => f.path));
67
+ const treeEntries = [];
68
+ for (const entry of treeData.tree) {
69
+ if (executablePaths.has(entry.path) &&
70
+ entry.type === "blob" &&
71
+ entry.mode !== "100755") {
72
+ treeEntries.push({
73
+ path: entry.path,
74
+ mode: "100755",
75
+ type: "blob",
76
+ sha: entry.sha,
77
+ });
78
+ }
79
+ }
80
+ // If tree was truncated (>100k entries), check that all executable files were found
81
+ if (treeData.truncated) {
82
+ const foundPaths = new Set(treeData.tree.filter((e) => e.type === "blob").map((e) => e.path));
83
+ const missing = [...executablePaths].filter((p) => !foundPaths.has(p));
84
+ if (missing.length > 0) {
85
+ throw new SyncError(`File mode fixup incomplete: tree response was truncated (>100k entries) ` +
86
+ `and ${missing.length} executable file(s) were not found: ${missing.join(", ")}`);
87
+ }
88
+ }
89
+ if (treeEntries.length === 0) {
90
+ // All requested files are either already 100755 or absent from the tree.
91
+ // Absent files in a non-truncated tree means createCommitOnBranch did not
92
+ // include them (e.g., concurrent deletion) — safe to skip since there is
93
+ // no blob to patch.
94
+ return innerResult;
95
+ }
96
+ // 3. Create new tree with updated modes
97
+ const newTreeRaw = await client.call("POST", `${repoPath}/git/trees`, {
98
+ payload: { base_tree: treeSha, tree: treeEntries },
99
+ options: apiOpts,
100
+ });
101
+ const newTree = parseApiJson(newTreeRaw, "POST git tree");
102
+ // 4. Create fixup commit (message is not user-customizable; this is an
103
+ // internal implementation detail of the mode-patching decorator)
104
+ const newCommitRaw = await client.call("POST", `${repoPath}/git/commits`, {
105
+ payload: {
106
+ message: "chore: set executable file modes",
107
+ tree: newTree.sha,
108
+ parents: [parentSha],
109
+ },
110
+ options: apiOpts,
111
+ });
112
+ const newCommit = parseApiJson(newCommitRaw, "POST git commit");
113
+ // 5. Update branch ref (fast-forward only; force is not needed since the
114
+ // fixup commit's parent is always the content commit we just created).
115
+ // Branch names with slashes (e.g. "chore/sync-config") are passed verbatim —
116
+ // the GitHub REST API accepts literal slashes in ref paths.
117
+ await client.call("PATCH", `${repoPath}/git/refs/heads/${branchName}`, {
118
+ payload: { sha: newCommit.sha },
119
+ options: apiOpts,
120
+ });
121
+ return {
122
+ sha: newCommit.sha,
123
+ verified: true,
124
+ pushed: true,
125
+ };
126
+ }
127
+ }
@@ -1,6 +1,7 @@
1
1
  export type { PRMergeConfig, FileChange, FileAction, IGitOps, ILocalGitOps, IPRStrategy, GitAuthOptions, PRResult, ICommitStrategy, } from "./types.js";
2
2
  export type { GitOpsOptions } from "./git-ops.js";
3
3
  export { createCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
4
+ export { FileModeFixupCommitStrategy } from "./file-mode-fixup-commit-strategy.js";
4
5
  export type { GitHubAppTokenManager } from "./github-app-token-manager.js";
5
6
  export { GitOps } from "./git-ops.js";
6
7
  export { AuthenticatedGitOps } from "./authenticated-git-ops.js";
package/dist/vcs/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Commit strategies
2
2
  export { createCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
3
+ export { FileModeFixupCommitStrategy } from "./file-mode-fixup-commit-strategy.js";
3
4
  // Git operations
4
5
  export { GitOps } from "./git-ops.js";
5
6
  export { AuthenticatedGitOps } from "./authenticated-git-ops.js";
@@ -146,6 +146,9 @@ export interface FileAction {
146
146
  export interface FileChange {
147
147
  path: string;
148
148
  content: string | null;
149
+ /** Git file mode. Only set for executable files ("100755"). "100644" is included
150
+ * in the union for type completeness — non-executable files omit this field. */
151
+ mode?: "100755" | "100644";
149
152
  }
150
153
  export interface CommitOptions {
151
154
  repoInfo: RepoInfo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "5.5.0",
3
+ "version": "5.7.0",
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",