@aspruyt/xfg 5.6.0 → 6.0.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.
@@ -420,9 +420,9 @@ export async function runSync(options, deps = {}) {
420
420
  getLogger().log(`Found ${config.repos.length} repositories to process`);
421
421
  getLogger().log(`Target files: ${formatFileNames(fileNames)}`);
422
422
  getLogger().log(`Branch: ${branchName}\n`);
423
- const tokenManager = createTokenManager(process.env.XFG_GITHUB_APP_ID && process.env.XFG_GITHUB_APP_PRIVATE_KEY
423
+ const tokenManager = createTokenManager(process.env.XFG_GITHUB_CLIENT_ID && process.env.XFG_GITHUB_APP_PRIVATE_KEY
424
424
  ? {
425
- appId: process.env.XFG_GITHUB_APP_ID,
425
+ clientId: process.env.XFG_GITHUB_CLIENT_ID,
426
426
  privateKey: process.env.XFG_GITHUB_APP_PRIVATE_KEY,
427
427
  }
428
428
  : undefined);
@@ -316,6 +316,7 @@ export class GitHubLifecycleProvider {
316
316
  const command = parts.join(" ");
317
317
  await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), {
318
318
  retries: this.retries,
319
+ permanentErrorPatterns: POST_CREATE_PERMANENT_PATTERNS,
319
320
  });
320
321
  }
321
322
  /**
@@ -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 {
@@ -3,7 +3,7 @@ import type { ICommitStrategy } from "./types.js";
3
3
  import { GitHubAppTokenManager } from "./github-app-token-manager.js";
4
4
  import type { ICommandExecutor } from "../shared/command-executor.js";
5
5
  interface GitHubAppCredentials {
6
- appId: string;
6
+ clientId: string;
7
7
  privateKey: string;
8
8
  }
9
9
  /**
@@ -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.
@@ -9,15 +10,17 @@ export function createTokenManager(credentials) {
9
10
  if (!credentials) {
10
11
  return null;
11
12
  }
12
- return new GitHubAppTokenManager(credentials.appId, credentials.privateKey);
13
+ return new GitHubAppTokenManager(credentials.clientId, 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
+ }
@@ -4,7 +4,7 @@ import type { GitHubRepoInfo } from "../shared/repo-detector.js";
4
4
  * Handles JWT generation, installation discovery, and token caching.
5
5
  */
6
6
  export declare class GitHubAppTokenManager {
7
- private readonly appId;
7
+ private readonly clientId;
8
8
  private readonly privateKey;
9
9
  /** Map of "apiHost:owner" -> installation ID */
10
10
  private installations;
@@ -12,7 +12,7 @@ export declare class GitHubAppTokenManager {
12
12
  private discoveredHosts;
13
13
  /** Map of "apiHost:owner" -> cached token */
14
14
  private tokenCache;
15
- constructor(appId: string, privateKey: string);
15
+ constructor(clientId: string, privateKey: string);
16
16
  /**
17
17
  * Generates a JWT for GitHub App authentication.
18
18
  * The JWT is signed with RS256 and valid for 10 minutes.
@@ -14,7 +14,7 @@ async function assertOkResponse(res, context) {
14
14
  * Handles JWT generation, installation discovery, and token caching.
15
15
  */
16
16
  export class GitHubAppTokenManager {
17
- appId;
17
+ clientId;
18
18
  privateKey;
19
19
  /** Map of "apiHost:owner" -> installation ID */
20
20
  installations = new Map();
@@ -22,8 +22,8 @@ export class GitHubAppTokenManager {
22
22
  discoveredHosts = new Set();
23
23
  /** Map of "apiHost:owner" -> cached token */
24
24
  tokenCache = new Map();
25
- constructor(appId, privateKey) {
26
- this.appId = appId;
25
+ constructor(clientId, privateKey) {
26
+ this.clientId = clientId;
27
27
  this.privateKey = privateKey;
28
28
  }
29
29
  /**
@@ -39,7 +39,7 @@ export class GitHubAppTokenManager {
39
39
  const payload = {
40
40
  iat: now - 60, // Issued 60 seconds ago to account for clock drift
41
41
  exp: now + 600, // Expires in 10 minutes
42
- iss: this.appId,
42
+ iss: this.clientId,
43
43
  };
44
44
  const encodedHeader = base64UrlEncode(JSON.stringify(header));
45
45
  const encodedPayload = base64UrlEncode(JSON.stringify(payload));
@@ -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.6.0",
3
+ "version": "6.0.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",