@aspruyt/xfg 6.0.0 → 6.0.2

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 (99) hide show
  1. package/dist/cli/sync-command.js +33 -35
  2. package/dist/cli/types.d.ts +12 -11
  3. package/dist/{output → cli}/unified-summary.d.ts +3 -3
  4. package/dist/{output → cli}/unified-summary.js +4 -4
  5. package/dist/config/file-reference-resolver.js +24 -56
  6. package/dist/config/normalizer.js +29 -40
  7. package/dist/config/validator.js +94 -102
  8. package/dist/lifecycle/ado-migration-source.d.ts +1 -1
  9. package/dist/lifecycle/ado-migration-source.js +1 -1
  10. package/dist/lifecycle/github-lifecycle-provider.d.ts +5 -6
  11. package/dist/lifecycle/github-lifecycle-provider.js +50 -20
  12. package/dist/lifecycle/lifecycle-formatter.d.ts +2 -1
  13. package/dist/lifecycle/lifecycle-formatter.js +1 -1
  14. package/dist/lifecycle/lifecycle-helpers.d.ts +1 -1
  15. package/dist/lifecycle/repo-lifecycle-manager.d.ts +1 -1
  16. package/dist/lifecycle/repo-lifecycle-manager.js +16 -6
  17. package/dist/lifecycle/types.d.ts +30 -8
  18. package/dist/output/lifecycle-report.d.ts +4 -2
  19. package/dist/output/settings-report.d.ts +4 -4
  20. package/dist/repo/detector.d.ts +8 -0
  21. package/dist/{shared/repo-detector.js → repo/detector.js} +1 -4
  22. package/dist/repo/index.d.ts +4 -0
  23. package/dist/repo/index.js +3 -0
  24. package/dist/{shared/repo-metadata-provider.d.ts → repo/metadata-provider.d.ts} +3 -3
  25. package/dist/{shared/repo-metadata-provider.js → repo/metadata-provider.js} +3 -3
  26. package/dist/{shared/repo-detector.d.ts → repo/types.d.ts} +1 -7
  27. package/dist/repo/types.js +1 -0
  28. package/dist/{shared/repo-info-utils.d.ts → repo/utils.d.ts} +1 -1
  29. package/dist/{shared/repo-info-utils.js → repo/utils.js} +1 -1
  30. package/dist/settings/base-processor.d.ts +1 -1
  31. package/dist/settings/base-processor.js +1 -1
  32. package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +1 -1
  33. package/dist/settings/code-scanning/github-code-scanning-strategy.js +1 -1
  34. package/dist/settings/code-scanning/processor.d.ts +2 -2
  35. package/dist/settings/code-scanning/types.d.ts +1 -1
  36. package/dist/settings/index.d.ts +1 -1
  37. package/dist/settings/labels/formatter.js +16 -11
  38. package/dist/settings/labels/github-labels-strategy.d.ts +1 -1
  39. package/dist/settings/labels/github-labels-strategy.js +1 -1
  40. package/dist/settings/labels/processor.d.ts +1 -1
  41. package/dist/settings/labels/types.d.ts +1 -1
  42. package/dist/settings/repo-settings/diff.d.ts +1 -1
  43. package/dist/settings/repo-settings/diff.js +1 -1
  44. package/dist/settings/repo-settings/formatter.js +2 -4
  45. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +4 -4
  46. package/dist/settings/repo-settings/github-repo-settings-strategy.js +4 -4
  47. package/dist/settings/repo-settings/processor.d.ts +2 -2
  48. package/dist/settings/repo-settings/processor.js +5 -5
  49. package/dist/settings/repo-settings/types.d.ts +4 -4
  50. package/dist/settings/rulesets/diff-algorithm.js +1 -1
  51. package/dist/settings/rulesets/formatter.js +0 -3
  52. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +1 -1
  53. package/dist/settings/rulesets/github-ruleset-strategy.js +1 -1
  54. package/dist/settings/rulesets/processor.d.ts +1 -1
  55. package/dist/settings/rulesets/types.d.ts +1 -1
  56. package/dist/shared/command-executor.js +3 -3
  57. package/dist/shared/gh-api-utils.d.ts +7 -4
  58. package/dist/shared/gh-api-utils.js +2 -2
  59. package/dist/shared/retry-utils.js +1 -1
  60. package/dist/shared/xfg-template.d.ts +22 -2
  61. package/dist/sync/auth-options-builder.d.ts +1 -1
  62. package/dist/sync/auth-options-builder.js +1 -1
  63. package/dist/sync/branch-manager.d.ts +1 -1
  64. package/dist/sync/commit-push-manager.d.ts +2 -2
  65. package/dist/sync/commit-push-manager.js +5 -3
  66. package/dist/sync/file-sync-orchestrator.d.ts +1 -1
  67. package/dist/sync/file-sync-strategy.d.ts +1 -1
  68. package/dist/sync/file-writer.js +44 -10
  69. package/dist/sync/repository-processor.d.ts +1 -1
  70. package/dist/sync/repository-session.d.ts +1 -1
  71. package/dist/sync/sync-workflow.d.ts +1 -1
  72. package/dist/sync/sync-workflow.js +2 -1
  73. package/dist/sync/types.d.ts +7 -4
  74. package/dist/vcs/{azure-pr-strategy.d.ts → ado-pr-strategy.d.ts} +2 -2
  75. package/dist/vcs/{azure-pr-strategy.js → ado-pr-strategy.js} +4 -4
  76. package/dist/vcs/authenticated-git-ops.d.ts +2 -0
  77. package/dist/vcs/authenticated-git-ops.js +6 -0
  78. package/dist/vcs/commit-strategy-selector.d.ts +1 -1
  79. package/dist/vcs/commit-strategy-selector.js +1 -1
  80. package/dist/vcs/file-mode-fixup-commit-strategy.d.ts +8 -6
  81. package/dist/vcs/file-mode-fixup-commit-strategy.js +79 -30
  82. package/dist/vcs/git-ops.d.ts +15 -3
  83. package/dist/vcs/git-ops.js +57 -24
  84. package/dist/vcs/github-app-token-manager.d.ts +1 -1
  85. package/dist/vcs/github-pr-strategy.d.ts +1 -1
  86. package/dist/vcs/github-pr-strategy.js +4 -4
  87. package/dist/vcs/gitlab-pr-strategy.d.ts +1 -1
  88. package/dist/vcs/gitlab-pr-strategy.js +4 -4
  89. package/dist/vcs/graphql-commit-strategy.js +8 -3
  90. package/dist/vcs/index.d.ts +1 -1
  91. package/dist/vcs/pr-creator.d.ts +1 -1
  92. package/dist/vcs/pr-strategy-factory.d.ts +1 -1
  93. package/dist/vcs/pr-strategy-factory.js +3 -3
  94. package/dist/vcs/pr-strategy.d.ts +1 -1
  95. package/dist/vcs/pr-strategy.js +1 -1
  96. package/dist/vcs/types.d.ts +10 -3
  97. package/package.json +3 -3
  98. /package/dist/{shared → vcs}/sanitize-utils.d.ts +0 -0
  99. /package/dist/{shared → vcs}/sanitize-utils.js +0 -0
@@ -1,5 +1,5 @@
1
1
  import { createCommitStrategy } from "../vcs/index.js";
2
- import { getRepoDisplayName } from "../shared/repo-detector.js";
2
+ import { getRepoDisplayName } from "../repo/index.js";
3
3
  import { toErrorMessage } from "../shared/type-guards.js";
4
4
  export class CommitPushManager {
5
5
  log;
@@ -22,6 +22,7 @@ export class CommitPushManager {
22
22
  path,
23
23
  content: info.content,
24
24
  ...(info.mode ? { mode: info.mode } : {}),
25
+ ...(info.modeOnly ? { modeOnly: true } : {}),
25
26
  }));
26
27
  this.log.info("Staging changes...");
27
28
  await gitOps.stageAll();
@@ -35,6 +36,7 @@ export class CommitPushManager {
35
36
  const result = await commitStrategy.commit({
36
37
  repoInfo,
37
38
  branchName: pushBranch,
39
+ baseBranch: options.baseBranch,
38
40
  message: commitMessage,
39
41
  fileChanges: changes,
40
42
  workDir,
@@ -47,10 +49,10 @@ export class CommitPushManager {
47
49
  return { success: true };
48
50
  }
49
51
  catch (error) {
50
- return this.rethrowIfUnexpectedCommitError(error, isDirectMode, pushBranch, repoInfo);
52
+ return this.classifyCommitError(error, isDirectMode, pushBranch, repoInfo);
51
53
  }
52
54
  }
53
- rethrowIfUnexpectedCommitError(error, isDirectMode, pushBranch, repoInfo) {
55
+ classifyCommitError(error, isDirectMode, pushBranch, repoInfo) {
54
56
  const repoName = getRepoDisplayName(repoInfo);
55
57
  const message = toErrorMessage(error);
56
58
  if (isDirectMode &&
@@ -1,5 +1,5 @@
1
1
  import type { RepoConfig } from "../config/index.js";
2
- import type { RepoInfo } from "../shared/repo-detector.js";
2
+ import type { RepoInfo } from "../repo/index.js";
3
3
  import type { ILogger } from "../shared/logger.js";
4
4
  import type { IFileWriter, IManifestManager, SessionContext, ProcessorOptions, FileSyncResult, IFileSyncOrchestrator } from "./types.js";
5
5
  export declare class FileSyncOrchestrator implements IFileSyncOrchestrator {
@@ -1,5 +1,5 @@
1
1
  import type { RepoConfig } from "../config/index.js";
2
- import type { RepoInfo } from "../shared/repo-detector.js";
2
+ import type { RepoInfo } from "../repo/index.js";
3
3
  import type { IWorkStrategy, WorkResult, SessionContext, ProcessorOptions, IFileSyncOrchestrator } from "./types.js";
4
4
  /**
5
5
  * Strategy that performs full file synchronization.
@@ -30,6 +30,7 @@ export class FileWriter {
30
30
  const { gitOps, log } = deps;
31
31
  const fileChanges = new Map();
32
32
  const diffStats = createDiffStats();
33
+ const modeCache = new Map();
33
34
  for (const file of files) {
34
35
  const filePath = join(workDir, file.fileName);
35
36
  const fileExistsLocal = existsSync(filePath);
@@ -64,31 +65,54 @@ export class FileWriter {
64
65
  const action = fileExistsLocal ? "update" : "create";
65
66
  const existingContent = gitOps.getFileContent(file.fileName);
66
67
  const changed = gitOps.wouldChange(file.fileName, fileContent);
68
+ const desiredMode = shouldBeExecutable(file)
69
+ ? "100755"
70
+ : "100644";
71
+ const currentMode = await gitOps.getFileMode(file.fileName);
72
+ modeCache.set(file.fileName, currentMode);
73
+ const modeDiffers = currentMode !== null && currentMode !== desiredMode;
67
74
  if (changed) {
68
75
  const writeResult = {
69
76
  fileName: file.fileName,
70
77
  content: fileContent,
71
78
  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" } : {}),
79
+ ...(desiredMode === "100755" || modeDiffers
80
+ ? { mode: desiredMode }
81
+ : {}),
75
82
  };
76
- // Compute raw diff lines for text files (all modes)
77
83
  if (!isBinaryFile(file.fileName)) {
78
84
  writeResult.diffLines = computeUnifiedDiff(existingContent, fileContent);
79
85
  }
80
86
  fileChanges.set(file.fileName, writeResult);
81
87
  }
88
+ else if (modeDiffers) {
89
+ fileChanges.set(file.fileName, {
90
+ fileName: file.fileName,
91
+ content: null,
92
+ action: "update",
93
+ mode: desiredMode,
94
+ modeOnly: true,
95
+ });
96
+ }
82
97
  if (dryRun) {
83
- const status = getFileStatus(existingContent !== null, changed);
84
- incrementDiffStats(diffStats, status);
85
- const diffLines = generateDiff(existingContent, fileContent);
86
- log.fileDiff(file.fileName, status, diffLines);
98
+ if (changed) {
99
+ const status = getFileStatus(existingContent !== null, changed);
100
+ incrementDiffStats(diffStats, status);
101
+ const diffLines = generateDiff(existingContent, fileContent);
102
+ log.fileDiff(file.fileName, status, diffLines);
103
+ }
104
+ else if (modeDiffers) {
105
+ incrementDiffStats(diffStats, "MODIFIED");
106
+ log.info(`Would change mode: ${file.fileName} ${currentMode} -> ${desiredMode}`);
107
+ }
87
108
  }
88
109
  else if (changed) {
89
110
  incrementDiffStats(diffStats, action === "create" ? "NEW" : "MODIFIED");
90
111
  gitOps.writeFile(file.fileName, fileContent);
91
112
  }
113
+ else if (modeDiffers) {
114
+ incrementDiffStats(diffStats, "MODIFIED");
115
+ }
92
116
  }
93
117
  // Separate pass for executable permissions: git add must happen after file
94
118
  // content is written, and setExecutable needs the file to already be tracked.
@@ -97,10 +121,20 @@ export class FileWriter {
97
121
  if (tracked?.action === "skip") {
98
122
  continue;
99
123
  }
100
- if (shouldBeExecutable(file)) {
101
- log.info(`Setting executable: ${file.fileName}`);
124
+ const desired = shouldBeExecutable(file);
125
+ const currentMode = modeCache.get(file.fileName) ?? null;
126
+ if (desired && currentMode !== "100755") {
127
+ log.info(ctx.dryRun
128
+ ? `Would set executable: ${file.fileName}`
129
+ : `Setting executable: ${file.fileName}`);
102
130
  await gitOps.setExecutable(file.fileName);
103
131
  }
132
+ else if (!desired && currentMode === "100755") {
133
+ log.info(ctx.dryRun
134
+ ? `Would clear executable: ${file.fileName}`
135
+ : `Clearing executable: ${file.fileName}`);
136
+ await gitOps.clearExecutable(file.fileName);
137
+ }
104
138
  }
105
139
  return { fileChanges, diffStats };
106
140
  }
@@ -1,5 +1,5 @@
1
1
  import type { RepoConfig } from "../config/index.js";
2
- import type { RepoInfo } from "../shared/repo-detector.js";
2
+ import type { RepoInfo } from "../repo/index.js";
3
3
  import type { ILogger } from "../shared/logger.js";
4
4
  import { type GitHubAppTokenManager } from "../vcs/index.js";
5
5
  import type { IFileWriter, IManifestManager, IBranchManager, IAuthOptionsBuilder, IRepositorySession, ICommitPushManager, IFileSyncOrchestrator, IPRMergeHandler, ISyncWorkflow, IRepositoryProcessor, GitOpsFactory, ProcessorOptions, ProcessorResult } from "./types.js";
@@ -1,4 +1,4 @@
1
- import type { RepoInfo } from "../shared/repo-detector.js";
1
+ import type { RepoInfo } from "../repo/index.js";
2
2
  import type { ILogger } from "../shared/logger.js";
3
3
  import type { GitOpsFactory, SessionOptions, SessionContext, IRepositorySession } from "./types.js";
4
4
  export declare class RepositorySession implements IRepositorySession {
@@ -1,5 +1,5 @@
1
1
  import type { RepoConfig } from "../config/index.js";
2
- import { type RepoInfo } from "../shared/repo-detector.js";
2
+ import { type RepoInfo } from "../repo/index.js";
3
3
  import type { DebugInfoLog } from "../shared/logger.js";
4
4
  import type { ISyncWorkflow, IWorkStrategy, IAuthOptionsBuilder, IRepositorySession, IBranchManager, ICommitPushManager, IPRMergeHandler, ProcessorOptions, ProcessorResult } from "./types.js";
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { getRepoDisplayName } from "../shared/repo-detector.js";
1
+ import { getRepoDisplayName } from "../repo/index.js";
2
2
  import { safeCleanup } from "../shared/cleanup-utils.js";
3
3
  /**
4
4
  * Orchestrates the common sync workflow steps.
@@ -69,6 +69,7 @@ export class SyncWorkflow {
69
69
  fileChanges: workResult.fileChanges,
70
70
  commitMessage: workResult.commitMessage,
71
71
  pushBranch,
72
+ baseBranch: session.baseBranch,
72
73
  isDirectMode,
73
74
  hasAppCredentials: options.hasAppCredentials,
74
75
  });
@@ -1,6 +1,7 @@
1
1
  import type { FileContent, RepoConfig } from "../config/index.js";
2
- import type { RepoInfo } from "../shared/repo-detector.js";
3
- import type { ILocalGitOps, IGitOps, GitAuthOptions, GitOpsOptions, FileAction } from "../vcs/index.js";
2
+ import type { RepoInfo } from "../repo/index.js";
3
+ import type { ActiveAction } from "../settings/index.js";
4
+ import type { ILocalGitOps, IGitOps, GitAuthOptions, GitOpsOptions, FileAction, FileActionKind } from "../vcs/index.js";
4
5
  import type { DiffStats } from "./diff-utils.js";
5
6
  import type { ILogger } from "../shared/logger.js";
6
7
  import type { XfgManifest } from "./manifest.js";
@@ -9,11 +10,12 @@ export type GitOpsFactory = (options: GitOpsOptions, auth?: GitAuthOptions, retr
9
10
  export interface FileWriteResult {
10
11
  fileName: string;
11
12
  content: string | null;
12
- action: "create" | "update" | "delete" | "skip";
13
+ action: FileActionKind;
13
14
  diffLines?: string[];
14
15
  /** Git file mode. Only set for executable files ("100755"). "100644" is included
15
16
  * in the union for type completeness — non-executable files omit this field. */
16
17
  mode?: "100755" | "100644";
18
+ modeOnly?: true;
17
19
  }
18
20
  export interface FileWriteContext {
19
21
  repoInfo: RepoInfo;
@@ -105,6 +107,7 @@ export interface CommitPushOptions extends RunContext {
105
107
  fileChanges: Map<string, FileWriteResult>;
106
108
  commitMessage: string;
107
109
  pushBranch: string;
110
+ baseBranch: string;
108
111
  isDirectMode: boolean;
109
112
  hasAppCredentials?: boolean;
110
113
  }
@@ -137,7 +140,7 @@ export interface ProcessorOptions {
137
140
  }
138
141
  export interface FileChangeDetail {
139
142
  path: string;
140
- action: "create" | "update" | "delete";
143
+ action: ActiveAction;
141
144
  diffLines?: string[];
142
145
  }
143
146
  export interface ProcessorResult {
@@ -3,7 +3,7 @@ import { BasePRStrategy } from "./pr-strategy.js";
3
3
  import type { IPRStrategyLogger } from "./pr-strategy.js";
4
4
  import type { PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./types.js";
5
5
  import type { ICommandExecutor } from "../shared/command-executor.js";
6
- export declare class AzurePRStrategy extends BasePRStrategy {
6
+ export declare class AdoPRStrategy extends BasePRStrategy {
7
7
  constructor(executor: ICommandExecutor, log?: IPRStrategyLogger);
8
8
  private getOrgUrl;
9
9
  private buildPRUrl;
@@ -12,7 +12,7 @@ export declare class AzurePRStrategy extends BasePRStrategy {
12
12
  * Returns the raw PR ID string, or null if none found.
13
13
  */
14
14
  private findExistingPRId;
15
- checkExistingPR(options: CloseExistingPROptions): Promise<string | null>;
15
+ findExistingPRUrl(options: CloseExistingPROptions): Promise<string | null>;
16
16
  closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
17
17
  create(options: PRStrategyOptions): Promise<PRResult>;
18
18
  /**
@@ -1,16 +1,16 @@
1
1
  import { existsSync, writeFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { escapeShellArg } from "../shared/shell-utils.js";
4
- import { assertAzureDevOpsRepo, } from "../shared/repo-detector.js";
4
+ import { assertAzureDevOpsRepo, } from "../repo/index.js";
5
5
  import { SyncError } from "../shared/errors.js";
6
6
  import { BasePRStrategy } from "./pr-strategy.js";
7
7
  import { withRetry, isPermanentError } from "../shared/retry-utils.js";
8
8
  import { toErrorMessage } from "../shared/type-guards.js";
9
9
  import { safeCleanup } from "../shared/cleanup-utils.js";
10
10
  import { NO_OP_DEBUG_LOG } from "../shared/logger.js";
11
- import { sanitizeCredentials } from "../shared/sanitize-utils.js";
11
+ import { sanitizeCredentials } from "./sanitize-utils.js";
12
12
  import { getStderr } from "../shared/command-executor.js";
13
- export class AzurePRStrategy extends BasePRStrategy {
13
+ export class AdoPRStrategy extends BasePRStrategy {
14
14
  constructor(executor, log) {
15
15
  super(executor, log);
16
16
  this.bodyFilePath = ".pr-description.md";
@@ -43,7 +43,7 @@ export class AzurePRStrategy extends BasePRStrategy {
43
43
  return null;
44
44
  }
45
45
  }
46
- async checkExistingPR(options) {
46
+ async findExistingPRUrl(options) {
47
47
  const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
48
48
  assertAzureDevOpsRepo(repoInfo, "Azure PR strategy");
49
49
  const azureRepoInfo = repoInfo;
@@ -33,6 +33,8 @@ export declare class AuthenticatedGitOps implements IGitOps {
33
33
  createBranch(branchName: string): Promise<void>;
34
34
  writeFile(fileName: string, content: string): void;
35
35
  setExecutable(fileName: string): Promise<void>;
36
+ clearExecutable(fileName: string): Promise<void>;
37
+ getFileMode(fileName: string): Promise<"100755" | "100644" | null>;
36
38
  getFileContent(fileName: string): string | null;
37
39
  wouldChange(fileName: string, content: string): boolean;
38
40
  hasChanges(): Promise<boolean>;
@@ -45,6 +45,12 @@ export class AuthenticatedGitOps {
45
45
  setExecutable(fileName) {
46
46
  return this.localOps.setExecutable(fileName);
47
47
  }
48
+ clearExecutable(fileName) {
49
+ return this.localOps.clearExecutable(fileName);
50
+ }
51
+ getFileMode(fileName) {
52
+ return this.localOps.getFileMode(fileName);
53
+ }
48
54
  getFileContent(fileName) {
49
55
  return this.localOps.getFileContent(fileName);
50
56
  }
@@ -1,4 +1,4 @@
1
- import { type RepoInfo } from "../shared/repo-detector.js";
1
+ import { type RepoInfo } from "../repo/index.js";
2
2
  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";
@@ -1,4 +1,4 @@
1
- import { isGitHubRepo } from "../shared/repo-detector.js";
1
+ import { isGitHubRepo } from "../repo/index.js";
2
2
  import { GitCommitStrategy } from "./git-commit-strategy.js";
3
3
  import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
4
4
  import { FileModeFixupCommitStrategy } from "./file-mode-fixup-commit-strategy.js";
@@ -9,10 +9,11 @@ export type GhApiClientFactory = (executor: ICommandExecutor, retries: number, c
9
9
  * The GitHub GraphQL createCommitOnBranch mutation cannot set file modes.
10
10
  * After the inner strategy (GraphQLCommitStrategy) creates the content commit,
11
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.
12
+ * patches tree modes to match the desired mode (100755 or 100644).
13
13
  *
14
- * Only activates when fileChanges contain entries with mode "100755".
15
- * When no executable files are present, delegates directly to the inner strategy.
14
+ * Activates when fileChanges contain entries with an explicit `mode` field
15
+ * or `modeOnly` flag. When no such entries are present, delegates directly
16
+ * to the inner strategy.
16
17
  */
17
18
  export declare class FileModeFixupCommitStrategy implements ICommitStrategy {
18
19
  private readonly inner;
@@ -20,12 +21,13 @@ export declare class FileModeFixupCommitStrategy implements ICommitStrategy {
20
21
  private readonly clientFactory;
21
22
  constructor(inner: ICommitStrategy, executor: ICommandExecutor, clientFactory?: GhApiClientFactory);
22
23
  commit(options: CommitOptions): Promise<CommitResult>;
24
+ private resolveBranchHeadSha;
23
25
  /**
24
- * Create a fixup commit that changes file modes from 100644 to 100755.
26
+ * Create a fixup commit that patches file modes (100644 100755).
25
27
  *
26
28
  * 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
+ * 1. GET the parent commit to find its tree SHA
30
+ * 2. GET the tree (recursive) to find blob SHAs for target files
29
31
  * 3. POST a new tree with updated modes (base_tree carries forward unchanged)
30
32
  * 4. POST a new commit with the new tree
31
33
  * 5. PATCH the branch ref to point to the new commit
@@ -1,7 +1,8 @@
1
- import { isGitHubRepo } from "../shared/repo-detector.js";
1
+ import { isGitHubRepo } from "../repo/index.js";
2
2
  import { GhApiClient } from "../shared/gh-api-utils.js";
3
3
  import { parseApiJson } from "../shared/json-utils.js";
4
4
  import { SyncError } from "../shared/errors.js";
5
+ import { validateSafeBranchName } from "./graphql-commit-strategy.js";
5
6
  const defaultClientFactory = (executor, retries, cwd) => new GhApiClient(executor, retries, cwd);
6
7
  /**
7
8
  * Decorator that adds a follow-up commit to fix executable file modes.
@@ -9,10 +10,11 @@ const defaultClientFactory = (executor, retries, cwd) => new GhApiClient(executo
9
10
  * The GitHub GraphQL createCommitOnBranch mutation cannot set file modes.
10
11
  * After the inner strategy (GraphQLCommitStrategy) creates the content commit,
11
12
  * 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
+ * patches tree modes to match the desired mode (100755 or 100644).
13
14
  *
14
- * Only activates when fileChanges contain entries with mode "100755".
15
- * When no executable files are present, delegates directly to the inner strategy.
15
+ * Activates when fileChanges contain entries with an explicit `mode` field
16
+ * or `modeOnly` flag. When no such entries are present, delegates directly
17
+ * to the inner strategy.
16
18
  */
17
19
  export class FileModeFixupCommitStrategy {
18
20
  inner;
@@ -24,26 +26,72 @@ export class FileModeFixupCommitStrategy {
24
26
  this.clientFactory = clientFactory;
25
27
  }
26
28
  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);
29
+ validateSafeBranchName(options.branchName);
30
+ const executableFiles = options.fileChanges.filter((fc) => fc.modeOnly === true || fc.mode !== undefined);
31
+ const hasContentChanges = options.fileChanges.some((fc) => !fc.modeOnly);
30
32
  if (executableFiles.length === 0) {
31
- return innerResult;
33
+ return this.inner.commit(options);
32
34
  }
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
35
  if (!isGitHubRepo(options.repoInfo)) {
37
- return innerResult;
36
+ return this.inner.commit(options);
37
+ }
38
+ let parentSha;
39
+ let baseResult;
40
+ if (hasContentChanges) {
41
+ baseResult = await this.inner.commit(options);
42
+ parentSha = baseResult.sha;
43
+ }
44
+ else {
45
+ parentSha = await this.resolveBranchHeadSha(options.repoInfo, options.branchName, options.baseBranch, options.workDir, options.retries ?? 3, options.token);
46
+ baseResult = { sha: parentSha, verified: true, pushed: true };
47
+ }
48
+ return await this.createFixupCommit(options.repoInfo, options.branchName, baseResult, executableFiles, options.workDir, options.retries ?? 3, options.token);
49
+ }
50
+ async resolveBranchHeadSha(repoInfo, branchName, baseBranch, workDir, retries, token) {
51
+ validateSafeBranchName(branchName);
52
+ if (baseBranch !== undefined) {
53
+ validateSafeBranchName(baseBranch);
54
+ }
55
+ const client = this.clientFactory(this.executor, retries, workDir);
56
+ const apiOpts = { token, host: repoInfo.host };
57
+ const repoPath = `repos/${repoInfo.owner}/${repoInfo.repo}`;
58
+ const getBranchRef = async (ref) => {
59
+ const raw = await client.call("GET", `${repoPath}/git/ref/heads/${ref}`, {
60
+ options: apiOpts,
61
+ });
62
+ const parsed = parseApiJson(raw, "GET git ref");
63
+ return parsed.object.sha;
64
+ };
65
+ try {
66
+ return await getBranchRef(branchName);
67
+ }
68
+ catch (err) {
69
+ const is404 = err instanceof Error && /\b404\b|Not Found/i.test(err.message);
70
+ if (!is404 || !baseBranch)
71
+ throw err;
72
+ const baseSha = await getBranchRef(baseBranch);
73
+ try {
74
+ await client.call("POST", `${repoPath}/git/refs`, {
75
+ payload: { ref: `refs/heads/${branchName}`, sha: baseSha },
76
+ options: apiOpts,
77
+ });
78
+ return baseSha;
79
+ }
80
+ catch (createErr) {
81
+ const alreadyExists = createErr instanceof Error &&
82
+ /Reference already exists/i.test(createErr.message);
83
+ if (!alreadyExists)
84
+ throw createErr;
85
+ return await getBranchRef(branchName);
86
+ }
38
87
  }
39
- return await this.createFixupCommit(options.repoInfo, options.branchName, innerResult, executableFiles, options.workDir, options.retries ?? 3, options.token);
40
88
  }
41
89
  /**
42
- * Create a fixup commit that changes file modes from 100644 to 100755.
90
+ * Create a fixup commit that patches file modes (100644 100755).
43
91
  *
44
92
  * 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
93
+ * 1. GET the parent commit to find its tree SHA
94
+ * 2. GET the tree (recursive) to find blob SHAs for target files
47
95
  * 3. POST a new tree with updated modes (base_tree carries forward unchanged)
48
96
  * 4. POST a new commit with the new tree
49
97
  * 5. PATCH the branch ref to point to the new commit
@@ -63,31 +111,32 @@ export class FileModeFixupCommitStrategy {
63
111
  // 2. Get tree entries to find blob SHAs
64
112
  const treeRaw = await client.call("GET", `${repoPath}/git/trees/${treeSha}?recursive=1`, { options: apiOpts });
65
113
  const treeData = parseApiJson(treeRaw, "GET git tree");
66
- const executablePaths = new Set(executableFiles.map((f) => f.path));
67
114
  const treeEntries = [];
115
+ const requestedByPath = new Map(executableFiles.map((f) => [f.path, f]));
68
116
  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
- }
117
+ const requested = requestedByPath.get(entry.path);
118
+ if (!requested || entry.type !== "blob")
119
+ continue;
120
+ const desiredMode = requested.mode ?? "100755";
121
+ if (entry.mode === desiredMode)
122
+ continue;
123
+ treeEntries.push({
124
+ path: entry.path,
125
+ mode: desiredMode,
126
+ type: "blob",
127
+ sha: entry.sha,
128
+ });
79
129
  }
80
- // If tree was truncated (>100k entries), check that all executable files were found
81
130
  if (treeData.truncated) {
82
131
  const foundPaths = new Set(treeData.tree.filter((e) => e.type === "blob").map((e) => e.path));
83
- const missing = [...executablePaths].filter((p) => !foundPaths.has(p));
132
+ const missing = [...requestedByPath.keys()].filter((p) => !foundPaths.has(p));
84
133
  if (missing.length > 0) {
85
134
  throw new SyncError(`File mode fixup incomplete: tree response was truncated (>100k entries) ` +
86
135
  `and ${missing.length} executable file(s) were not found: ${missing.join(", ")}`);
87
136
  }
88
137
  }
89
138
  if (treeEntries.length === 0) {
90
- // All requested files are either already 100755 or absent from the tree.
139
+ // All requested files already have the desired mode or are absent from the tree.
91
140
  // Absent files in a non-truncated tree means createCommitOnBranch did not
92
141
  // include them (e.g., concurrent deletion) — safe to skip since there is
93
142
  // no blob to patch.
@@ -9,9 +9,9 @@ export interface GitOpsOptions {
9
9
  log?: DebugLog;
10
10
  }
11
11
  export declare class GitOps implements ILocalGitOps {
12
- private readonly _workDir;
12
+ private readonly workDir;
13
13
  private readonly dryRun;
14
- private readonly _executor;
14
+ private readonly executor;
15
15
  private readonly log?;
16
16
  constructor(options: GitOpsOptions);
17
17
  private exec;
@@ -36,6 +36,18 @@ export declare class GitOps implements ILocalGitOps {
36
36
  * @param fileName - The file path relative to the work directory
37
37
  */
38
38
  setExecutable(fileName: string): Promise<void>;
39
+ /**
40
+ * Clears the executable bit on a file both on the filesystem and in git's index.
41
+ * Symmetric inverse of setExecutable.
42
+ * @param fileName - The file path relative to the work directory
43
+ */
44
+ clearExecutable(fileName: string): Promise<void>;
45
+ /**
46
+ * Returns the git index mode for a tracked file ("100755" or "100644"),
47
+ * or null if the file is not tracked.
48
+ * @param fileName - The file path relative to the work directory
49
+ */
50
+ getFileMode(fileName: string): Promise<"100755" | "100644" | null>;
39
51
  /**
40
52
  * Get the content of a file in the workspace.
41
53
  * Returns null if the file doesn't exist.
@@ -70,7 +82,7 @@ export declare class GitOps implements ILocalGitOps {
70
82
  /**
71
83
  * Stage all changes and commit with the given message.
72
84
  * Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
73
- * @returns true if a commit was made (or would be made in dry-run mode), false if there were no staged changes
85
+ * @returns true if a commit was made, or false if there were no staged changes. In dry-run mode, always returns true without inspecting the working tree.
74
86
  */
75
87
  commit(message: string): Promise<boolean>;
76
88
  /**