@aspruyt/xfg 4.0.4 → 5.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.
Files changed (74) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/program.d.ts +3 -0
  3. package/dist/cli/program.js +18 -13
  4. package/dist/cli/sync-command.js +62 -39
  5. package/dist/cli/sync-report-builder.js +7 -4
  6. package/dist/config/formatter.js +14 -9
  7. package/dist/config/merge.d.ts +2 -4
  8. package/dist/config/merge.js +15 -67
  9. package/dist/config/validator.js +2 -9
  10. package/dist/lifecycle/repo-lifecycle-factory.js +0 -4
  11. package/dist/output/github-summary.d.ts +3 -2
  12. package/dist/output/github-summary.js +1 -7
  13. package/dist/output/lifecycle-report.js +7 -14
  14. package/dist/output/sync-report.d.ts +2 -19
  15. package/dist/output/sync-report.js +16 -28
  16. package/dist/output/types.d.ts +19 -0
  17. package/dist/output/types.js +1 -0
  18. package/dist/output/unified-summary.d.ts +2 -1
  19. package/dist/output/unified-summary.js +4 -1
  20. package/dist/settings/base-processor.d.ts +3 -1
  21. package/dist/settings/base-processor.js +9 -5
  22. package/dist/settings/index.d.ts +1 -1
  23. package/dist/settings/labels/diff.d.ts +2 -1
  24. package/dist/settings/labels/formatter.js +2 -4
  25. package/dist/settings/labels/github-labels-strategy.js +2 -1
  26. package/dist/settings/labels/processor.js +0 -1
  27. package/dist/settings/repo-settings/github-repo-settings-strategy.js +2 -1
  28. package/dist/settings/rulesets/diff-algorithm.js +0 -1
  29. package/dist/settings/rulesets/diff.d.ts +2 -1
  30. package/dist/settings/rulesets/diff.js +37 -21
  31. package/dist/settings/rulesets/formatter.js +44 -38
  32. package/dist/settings/rulesets/github-ruleset-strategy.js +2 -1
  33. package/dist/settings/rulesets/processor.js +0 -1
  34. package/dist/shared/gh-api-utils.d.ts +8 -7
  35. package/dist/shared/gh-api-utils.js +2 -16
  36. package/dist/shared/interpolation-engine.d.ts +3 -0
  37. package/dist/shared/interpolation-engine.js +0 -3
  38. package/dist/shared/json-utils.d.ts +6 -0
  39. package/dist/shared/json-utils.js +16 -0
  40. package/dist/shared/repo-detector.js +0 -4
  41. package/dist/shared/xfg-template.d.ts +3 -0
  42. package/dist/shared/xfg-template.js +0 -20
  43. package/dist/sync/auth-options-builder.js +7 -1
  44. package/dist/sync/branch-manager.d.ts +1 -1
  45. package/dist/sync/commit-message.d.ts +1 -1
  46. package/dist/sync/commit-push-manager.d.ts +1 -1
  47. package/dist/sync/commit-push-manager.js +2 -2
  48. package/dist/sync/diff-utils.d.ts +15 -2
  49. package/dist/sync/diff-utils.js +50 -14
  50. package/dist/sync/file-sync-orchestrator.js +2 -4
  51. package/dist/sync/file-sync-strategy.js +11 -4
  52. package/dist/sync/file-writer.js +9 -4
  53. package/dist/sync/index.d.ts +2 -1
  54. package/dist/sync/index.js +1 -0
  55. package/dist/sync/manifest-manager.d.ts +1 -1
  56. package/dist/sync/manifest-manager.js +20 -6
  57. package/dist/sync/pr-merge-handler.js +6 -1
  58. package/dist/sync/repository-processor.js +8 -1
  59. package/dist/sync/types.d.ts +5 -4
  60. package/dist/vcs/authenticated-git-ops.d.ts +9 -1
  61. package/dist/vcs/authenticated-git-ops.js +7 -14
  62. package/dist/vcs/git-ops.js +29 -12
  63. package/dist/vcs/github-pr-strategy.js +6 -1
  64. package/dist/vcs/gitlab-pr-strategy.js +7 -2
  65. package/dist/vcs/graphql-commit-strategy.js +2 -1
  66. package/dist/vcs/index.d.ts +1 -0
  67. package/dist/vcs/index.js +2 -0
  68. package/dist/vcs/pr-creator.d.ts +5 -1
  69. package/dist/vcs/pr-creator.js +4 -4
  70. package/package.json +1 -1
  71. package/dist/output/index.d.ts +0 -5
  72. package/dist/output/index.js +0 -10
  73. package/dist/shared/index.d.ts +0 -15
  74. package/dist/shared/index.js +0 -30
@@ -86,26 +86,6 @@ function buildXfgConfig(ctx, options) {
86
86
  restoreEscaped: (content) => `\${xfg:${content}}`,
87
87
  };
88
88
  }
89
- /**
90
- * Interpolate xfg template variables in content.
91
- *
92
- * Supports these syntaxes:
93
- * - ${xfg:repo.name} - Repository name
94
- * - ${xfg:repo.owner} - Repository owner
95
- * - ${xfg:repo.fullName} - Full repository name (owner/repo)
96
- * - ${xfg:repo.url} - Git URL
97
- * - ${xfg:repo.platform} - Platform type (github, azure-devops, gitlab)
98
- * - ${xfg:repo.host} - Host domain
99
- * - ${xfg:file.name} - Current file name
100
- * - ${xfg:date} - Current date (YYYY-MM-DD)
101
- * - ${xfg:customVar} - Custom variable from vars config
102
- * - $${xfg:var} - Escape: outputs literal ${xfg:var}
103
- *
104
- * @param content - The content to process (object, string, or string[])
105
- * @param ctx - Template context with repo info and custom vars
106
- * @param options - Interpolation options (default: strict mode)
107
- * @returns Content with interpolated values
108
- */
109
89
  export function interpolateXfgContent(content, ctx, options = DEFAULT_OPTIONS) {
110
90
  const config = buildXfgConfig(ctx, options);
111
91
  if (typeof content === "string") {
@@ -19,7 +19,13 @@ export class AuthOptionsBuilder {
19
19
  return { ok: true, token, authOptions };
20
20
  }
21
21
  // Otherwise resolve via token manager / env fallback
22
- const resolved = await resolveGitHubToken(repoInfo, this.tokenManager, repoName, this.log, this.envToken);
22
+ const resolved = await resolveGitHubToken({
23
+ repoInfo,
24
+ tokenManager: this.tokenManager,
25
+ context: repoName,
26
+ log: this.log,
27
+ envToken: this.envToken,
28
+ });
23
29
  if (resolved.skipped) {
24
30
  return {
25
31
  ok: false,
@@ -1,4 +1,4 @@
1
- import type { IPRStrategy } from "../vcs/types.js";
1
+ import type { IPRStrategy } from "../vcs/index.js";
2
2
  import type { RepoInfo } from "../shared/repo-detector.js";
3
3
  import type { ICommandExecutor } from "../shared/command-executor.js";
4
4
  import type { IBranchManager, BranchSetupOptions } from "./types.js";
@@ -1,4 +1,4 @@
1
- import type { FileAction } from "../vcs/types.js";
1
+ import type { FileAction } from "../vcs/index.js";
2
2
  /**
3
3
  * Format a commit message based on the files being changed.
4
4
  *
@@ -1,4 +1,4 @@
1
- import type { ICommitStrategy } from "../vcs/types.js";
1
+ import type { ICommitStrategy } from "../vcs/index.js";
2
2
  import type { RepoInfo } from "../shared/repo-detector.js";
3
3
  import type { ICommandExecutor } from "../shared/command-executor.js";
4
4
  import type { CommitPushOptions, CommitPushResult, ICommitPushManager } from "./types.js";
@@ -46,7 +46,7 @@ export class CommitPushManager {
46
46
  return this.handleCommitError(error, isDirectMode, pushBranch, repoInfo);
47
47
  }
48
48
  }
49
- handleCommitError(error, isDirectMode, baseBranch, repoInfo) {
49
+ handleCommitError(error, isDirectMode, pushBranch, repoInfo) {
50
50
  const repoName = getRepoDisplayName(repoInfo);
51
51
  const message = toErrorMessage(error);
52
52
  if (isDirectMode &&
@@ -58,7 +58,7 @@ export class CommitPushManager {
58
58
  errorResult: {
59
59
  success: false,
60
60
  repoName,
61
- message: `Push to '${baseBranch}' was rejected (likely branch protection). ` +
61
+ message: `Push to '${pushBranch}' was rejected (likely branch protection). ` +
62
62
  `To use 'direct' mode, the target branch must allow direct pushes. ` +
63
63
  `Use 'merge: force' to create a PR and merge with admin privileges.`,
64
64
  },
@@ -6,11 +6,24 @@ export declare function getFileStatus(exists: boolean, changed: boolean): FileSt
6
6
  * Format a single diff line with appropriate color.
7
7
  */
8
8
  export declare function formatDiffLine(line: string): string;
9
+ /**
10
+ * Check if a file is a structured data file (JSON, JSON5, YAML, YML).
11
+ */
12
+ export declare function isStructuredDataFile(fileName: string): boolean;
13
+ /**
14
+ * Compute a unified diff between old and new content.
15
+ * Returns raw diff lines (no ANSI formatting).
16
+ *
17
+ * - oldContent === null → new file (all additions)
18
+ * - newContent === null → deleted file (all removals)
19
+ * - both null → empty array
20
+ */
21
+ export declare function computeUnifiedDiff(oldContent: string | null, newContent: string | null, contextLines?: number): string[];
9
22
  /**
10
23
  * Generate a unified diff between old and new content.
11
- * Returns an array of formatted diff lines.
24
+ * Returns an array of formatted (chalk-colored) diff lines.
12
25
  */
13
- export declare function generateDiff(oldContent: string | null, newContent: string, _fileName: string, contextLines?: number): string[];
26
+ export declare function generateDiff(oldContent: string | null, newContent: string, contextLines?: number): string[];
14
27
  export interface DiffStats {
15
28
  newCount: number;
16
29
  modifiedCount: number;
@@ -18,34 +18,70 @@ export function formatDiffLine(line) {
18
18
  return line;
19
19
  }
20
20
  /**
21
- * Generate a unified diff between old and new content.
22
- * Returns an array of formatted diff lines.
21
+ * Check if a file is a structured data file (JSON, JSON5, YAML, YML).
23
22
  */
24
- export function generateDiff(oldContent, newContent, _fileName, contextLines = 3) {
25
- const oldLines = oldContent ? oldContent.split("\n") : [];
26
- const newLines = newContent.split("\n");
27
- // For new files, show all lines as additions
23
+ export function isStructuredDataFile(fileName) {
24
+ return /\.(json|json5|ya?ml)$/i.test(fileName);
25
+ }
26
+ /**
27
+ * Compute a unified diff between old and new content.
28
+ * Returns raw diff lines (no ANSI formatting).
29
+ *
30
+ * - oldContent === null → new file (all additions)
31
+ * - newContent === null → deleted file (all removals)
32
+ * - both null → empty array
33
+ */
34
+ export function computeUnifiedDiff(oldContent, newContent, contextLines = 3) {
35
+ if (oldContent === null && newContent === null) {
36
+ return [];
37
+ }
38
+ // New file: all additions
28
39
  if (oldContent === null) {
29
- const result = [];
30
- for (const line of newLines) {
31
- result.push(formatDiffLine(`+${line}`));
40
+ const newLines = newContent.split("\n");
41
+ // Filter trailing empty string from split
42
+ const lines = newLines[newLines.length - 1] === "" ? newLines.slice(0, -1) : newLines;
43
+ if (lines.length === 0)
44
+ return [];
45
+ const result = [`@@ -0,0 +1,${lines.length} @@`];
46
+ for (const line of lines) {
47
+ result.push(`+${line}`);
48
+ }
49
+ return result;
50
+ }
51
+ // Deleted file: all removals
52
+ if (newContent === null) {
53
+ const oldLines = oldContent.split("\n");
54
+ const lines = oldLines[oldLines.length - 1] === "" ? oldLines.slice(0, -1) : oldLines;
55
+ if (lines.length === 0)
56
+ return [];
57
+ const result = [`@@ -1,${lines.length} +0,0 @@`];
58
+ for (const line of lines) {
59
+ result.push(`-${line}`);
32
60
  }
33
61
  return result;
34
62
  }
35
- // Simple LCS-based diff algorithm
63
+ // Modified file: LCS diff
64
+ const oldLines = oldContent.split("\n");
65
+ const newLines = newContent.split("\n");
36
66
  const hunks = computeDiffHunks(oldLines, newLines, contextLines);
37
- if (hunks.length === 0) {
67
+ if (hunks.length === 0)
38
68
  return [];
39
- }
40
69
  const result = [];
41
70
  for (const hunk of hunks) {
42
- result.push(formatDiffLine(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`));
71
+ result.push(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`);
43
72
  for (const line of hunk.lines) {
44
- result.push(formatDiffLine(line));
73
+ result.push(line);
45
74
  }
46
75
  }
47
76
  return result;
48
77
  }
78
+ /**
79
+ * Generate a unified diff between old and new content.
80
+ * Returns an array of formatted (chalk-colored) diff lines.
81
+ */
82
+ export function generateDiff(oldContent, newContent, contextLines = 3) {
83
+ return computeUnifiedDiff(oldContent, newContent, contextLines).map(formatDiffLine);
84
+ }
49
85
  /**
50
86
  * Compute diff hunks using a simple line-by-line comparison.
51
87
  * This is a simplified diff that shows changed regions with context.
@@ -1,5 +1,4 @@
1
1
  import { incrementDiffStats } from "./diff-utils.js";
2
- import { loadManifest } from "./manifest.js";
3
2
  export class FileSyncOrchestrator {
4
3
  fileWriter;
5
4
  manifestManager;
@@ -22,10 +21,9 @@ export class FileSyncOrchestrator {
22
21
  configId,
23
22
  hasAppCredentials: options.hasAppCredentials,
24
23
  }, { gitOps: session.gitOps, log: this.log });
25
- const existingManifest = loadManifest(workDir, this.log);
26
24
  const filesWithDeleteOrphaned = new Map(repoConfig.files.map((f) => [f.fileName, f.deleteOrphaned]));
27
- const { manifest: newManifest, filesToDelete } = this.manifestManager.processOrphans(workDir, configId, filesWithDeleteOrphaned);
28
- await this.manifestManager.deleteOrphans(filesToDelete, { dryRun: dryRun, noDelete: noDelete }, { gitOps: session.gitOps, log: this.log, fileChanges });
25
+ const { manifest: newManifest, existingManifest, filesToDelete, } = this.manifestManager.processOrphans(workDir, configId, filesWithDeleteOrphaned);
26
+ this.manifestManager.deleteOrphans(filesToDelete, { dryRun: dryRun, noDelete: noDelete }, { gitOps: session.gitOps, log: this.log, fileChanges });
29
27
  // Save manifest (may add to fileChanges)
30
28
  this.manifestManager.saveUpdatedManifest(workDir, newManifest, existingManifest, dryRun, fileChanges);
31
29
  // Count stats for entries added after writeFiles (orphan deletes + manifest).
@@ -15,10 +15,17 @@ export class FileSyncStrategy {
15
15
  }
16
16
  const fileChangeDetails = changedFiles
17
17
  .filter((f) => f.action !== "skip")
18
- .map((f) => ({
19
- path: f.fileName,
20
- action: f.action,
21
- }));
18
+ .map((f) => {
19
+ const detail = {
20
+ path: f.fileName,
21
+ action: f.action,
22
+ };
23
+ const writeResult = fileChanges.get(f.fileName);
24
+ if (writeResult?.diffLines) {
25
+ detail.diffLines = writeResult.diffLines;
26
+ }
27
+ return detail;
28
+ });
22
29
  return {
23
30
  fileChanges,
24
31
  changedFiles,
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { convertContentToString } from "../config/formatter.js";
4
4
  import { interpolateXfgContent } from "../shared/xfg-template.js";
5
- import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
5
+ import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, computeUnifiedDiff, isStructuredDataFile, } from "./diff-utils.js";
6
6
  /**
7
7
  * Determines if a file should be marked as executable.
8
8
  * .sh files are auto-executable unless explicit executable: false is set.
@@ -65,16 +65,21 @@ export class FileWriter {
65
65
  const existingContent = gitOps.getFileContent(file.fileName);
66
66
  const changed = gitOps.wouldChange(file.fileName, fileContent);
67
67
  if (changed) {
68
- fileChanges.set(file.fileName, {
68
+ const writeResult = {
69
69
  fileName: file.fileName,
70
70
  content: fileContent,
71
71
  action,
72
- });
72
+ };
73
+ // Compute raw diff lines for structured data files (all modes)
74
+ if (isStructuredDataFile(file.fileName)) {
75
+ writeResult.diffLines = computeUnifiedDiff(existingContent, fileContent);
76
+ }
77
+ fileChanges.set(file.fileName, writeResult);
73
78
  }
74
79
  if (dryRun) {
75
80
  const status = getFileStatus(existingContent !== null, changed);
76
81
  incrementDiffStats(diffStats, status);
77
- const diffLines = generateDiff(existingContent, fileContent, file.fileName);
82
+ const diffLines = generateDiff(existingContent, fileContent);
78
83
  log.fileDiff(file.fileName, status, diffLines);
79
84
  }
80
85
  else if (changed) {
@@ -1,3 +1,4 @@
1
1
  export type { DiffStats } from "./diff-utils.js";
2
- export type { GitOpsFactory, IAuthOptionsBuilder, IBranchManager, ICommitPushManager, IFileSyncOrchestrator, IPRMergeHandler, IRepositoryProcessor, IRepositorySession, IWorkStrategy, ProcessorResult, SessionContext, WorkResult, } from "./types.js";
2
+ export { computeUnifiedDiff, isStructuredDataFile, formatDiffLine, } from "./diff-utils.js";
3
+ export type { FileChangeDetail, GitOpsFactory, IAuthOptionsBuilder, IBranchManager, ICommitPushManager, IFileSyncOrchestrator, IPRMergeHandler, IRepositoryProcessor, IRepositorySession, IWorkStrategy, ProcessorResult, SessionContext, WorkResult, } from "./types.js";
3
4
  export { RepositoryProcessor } from "./repository-processor.js";
@@ -1 +1,2 @@
1
+ export { computeUnifiedDiff, isStructuredDataFile, formatDiffLine, } from "./diff-utils.js";
1
2
  export { RepositoryProcessor } from "./repository-processor.js";
@@ -10,6 +10,6 @@ export declare class ManifestManager implements IManifestManager {
10
10
  warn(msg: string): void;
11
11
  } | undefined);
12
12
  processOrphans(workDir: string, configId: string, filesWithDeleteOrphaned: Map<string, boolean | undefined>): OrphanProcessResult;
13
- deleteOrphans(filesToDelete: string[], options: OrphanDeleteOptions, deps: OrphanDeleteDeps): Promise<void>;
13
+ deleteOrphans(filesToDelete: string[], options: OrphanDeleteOptions, deps: OrphanDeleteDeps): void;
14
14
  saveUpdatedManifest(workDir: string, manifest: XfgManifest, existingManifest: XfgManifest | null, dryRun: boolean, fileChanges: Map<string, FileWriteResult>): void;
15
15
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadManifest, saveManifest, updateManifest, MANIFEST_FILENAME, } from "./manifest.js";
4
+ import { computeUnifiedDiff, isStructuredDataFile } from "./diff-utils.js";
4
5
  /**
5
6
  * Handles manifest loading, saving, and orphan detection.
6
7
  */
@@ -12,9 +13,9 @@ export class ManifestManager {
12
13
  processOrphans(workDir, configId, filesWithDeleteOrphaned) {
13
14
  const existingManifest = loadManifest(workDir, this.log);
14
15
  const { manifest, filesToDelete } = updateManifest(existingManifest, configId, filesWithDeleteOrphaned);
15
- return { manifest, filesToDelete };
16
+ return { manifest, existingManifest, filesToDelete };
16
17
  }
17
- async deleteOrphans(filesToDelete, options, deps) {
18
+ deleteOrphans(filesToDelete, options, deps) {
18
19
  const { dryRun, noDelete } = options;
19
20
  const { gitOps, log, fileChanges } = deps;
20
21
  if (filesToDelete.length === 0) {
@@ -29,11 +30,18 @@ export class ManifestManager {
29
30
  if (!gitOps.fileExists(fileName)) {
30
31
  continue;
31
32
  }
32
- fileChanges.set(fileName, {
33
+ const writeResult = {
33
34
  fileName,
34
35
  content: null,
35
36
  action: "delete",
36
- });
37
+ };
38
+ if (isStructuredDataFile(fileName)) {
39
+ const existingContent = gitOps.getFileContent(fileName);
40
+ if (existingContent !== null) {
41
+ writeResult.diffLines = computeUnifiedDiff(existingContent, null);
42
+ }
43
+ }
44
+ fileChanges.set(fileName, writeResult);
37
45
  if (dryRun) {
38
46
  log.fileDiff(fileName, "DELETED", []);
39
47
  }
@@ -56,11 +64,17 @@ export class ManifestManager {
56
64
  }
57
65
  const manifestExisted = existsSync(join(workDir, MANIFEST_FILENAME));
58
66
  const manifestContent = JSON.stringify(manifest, null, 2) + "\n";
59
- fileChanges.set(MANIFEST_FILENAME, {
67
+ const writeResult = {
60
68
  fileName: MANIFEST_FILENAME,
61
69
  content: manifestContent,
62
70
  action: manifestExisted ? "update" : "create",
63
- });
71
+ };
72
+ // Compute diff for the manifest (it's a JSON file)
73
+ const oldManifestContent = existingManifest
74
+ ? JSON.stringify(existingManifest, null, 2) + "\n"
75
+ : null;
76
+ writeResult.diffLines = computeUnifiedDiff(oldManifestContent, manifestContent);
77
+ fileChanges.set(MANIFEST_FILENAME, writeResult);
64
78
  if (!dryRun) {
65
79
  saveManifest(workDir, manifest);
66
80
  }
@@ -1,4 +1,4 @@
1
- import { createPR, mergePR } from "../vcs/pr-creator.js";
1
+ import { createPR, mergePR, getPRStrategy, } from "../vcs/index.js";
2
2
  export class PRMergeHandler {
3
3
  log;
4
4
  constructor(log) {
@@ -7,6 +7,9 @@ export class PRMergeHandler {
7
7
  async createAndMerge(input) {
8
8
  const { repoInfo, repoConfig, options, changedFiles, repoName, diffStats, fileChanges, } = input;
9
9
  this.log.info("Creating pull request...");
10
+ const strategy = options.dryRun
11
+ ? undefined
12
+ : getPRStrategy(repoInfo, options.executor, this.log);
10
13
  const prResult = await createPR({
11
14
  repoInfo,
12
15
  branchName: options.branchName,
@@ -20,6 +23,7 @@ export class PRMergeHandler {
20
23
  token: options.token,
21
24
  labels: repoConfig.prOptions?.labels,
22
25
  log: this.log,
26
+ strategy,
23
27
  });
24
28
  const mergeMode = repoConfig.prOptions?.merge ?? "auto";
25
29
  let mergeResult;
@@ -41,6 +45,7 @@ export class PRMergeHandler {
41
45
  executor: options.executor,
42
46
  token: options.token,
43
47
  log: this.log,
48
+ strategy,
44
49
  });
45
50
  mergeResult = {
46
51
  merged: result.merged ?? false,
@@ -20,7 +20,14 @@ export class RepositoryProcessor {
20
20
  const factory = gitOpsFactory ??
21
21
  ((opts, auth, retries) => {
22
22
  const gitOps = new GitOps({ ...opts, log: log });
23
- return new AuthenticatedGitOps(gitOps, opts.executor, opts.workDir, retries ?? 3, auth, log);
23
+ return new AuthenticatedGitOps({
24
+ localOps: gitOps,
25
+ executor: opts.executor,
26
+ workDir: opts.workDir,
27
+ retries: retries ?? 3,
28
+ auth,
29
+ log,
30
+ });
24
31
  });
25
32
  const tokenManager = components?.tokenManager ?? null;
26
33
  const fileWriter = components?.fileWriter ?? new FileWriter();
@@ -1,17 +1,16 @@
1
1
  import type { FileContent, RepoConfig } from "../config/types.js";
2
2
  import type { RepoInfo } from "../shared/repo-detector.js";
3
- import type { ILocalGitOps, IGitOps, GitAuthOptions } from "../vcs/types.js";
4
- import type { GitOpsOptions } from "../vcs/git-ops.js";
3
+ import type { ILocalGitOps, IGitOps, GitAuthOptions, GitOpsOptions, FileAction } from "../vcs/index.js";
5
4
  import type { DiffStats } from "./diff-utils.js";
6
5
  import type { ILogger } from "../shared/logger.js";
7
6
  import type { XfgManifest } from "./manifest.js";
8
7
  import type { ICommandExecutor } from "../shared/command-executor.js";
9
- import type { FileAction } from "../vcs/types.js";
10
8
  export type GitOpsFactory = (options: GitOpsOptions, auth?: GitAuthOptions, retries?: number) => IGitOps;
11
9
  export interface FileWriteResult {
12
10
  fileName: string;
13
11
  content: string | null;
14
12
  action: "create" | "update" | "delete" | "skip";
13
+ diffLines?: string[];
15
14
  }
16
15
  export interface FileWriteContext {
17
16
  repoInfo: RepoInfo;
@@ -36,6 +35,7 @@ export interface IFileWriter {
36
35
  }
37
36
  export interface OrphanProcessResult {
38
37
  manifest: XfgManifest;
38
+ existingManifest: XfgManifest | null;
39
39
  filesToDelete: string[];
40
40
  }
41
41
  export interface OrphanDeleteOptions {
@@ -49,7 +49,7 @@ export interface OrphanDeleteDeps {
49
49
  }
50
50
  export interface IManifestManager {
51
51
  processOrphans(workDir: string, configId: string, filesWithDeleteOrphaned: Map<string, boolean | undefined>): OrphanProcessResult;
52
- deleteOrphans(filesToDelete: string[], options: OrphanDeleteOptions, deps: OrphanDeleteDeps): Promise<void>;
52
+ deleteOrphans(filesToDelete: string[], options: OrphanDeleteOptions, deps: OrphanDeleteDeps): void;
53
53
  saveUpdatedManifest(workDir: string, manifest: XfgManifest, existingManifest: XfgManifest | null, dryRun: boolean, fileChanges: Map<string, FileWriteResult>): void;
54
54
  }
55
55
  /** Common runtime context shared across workflow step options bags. */
@@ -135,6 +135,7 @@ export interface ProcessorOptions {
135
135
  export interface FileChangeDetail {
136
136
  path: string;
137
137
  action: "create" | "update" | "delete";
138
+ diffLines?: string[];
138
139
  }
139
140
  export interface ProcessorResult {
140
141
  success: boolean;
@@ -8,6 +8,14 @@ import type { GitAuthOptions, ILocalGitOps, IGitOps } from "./types.js";
8
8
  * the remote origin. Subsequent operations (fetch, push, getDefaultBranch)
9
9
  * reuse that authenticated remote URL — no extra auth setup per operation.
10
10
  */
11
+ export interface AuthenticatedGitOpsOptions {
12
+ localOps: ILocalGitOps;
13
+ executor: ICommandExecutor;
14
+ workDir: string;
15
+ retries: number;
16
+ auth?: GitAuthOptions;
17
+ log?: DebugLog;
18
+ }
11
19
  export declare class AuthenticatedGitOps implements IGitOps {
12
20
  private readonly localOps;
13
21
  private readonly executor;
@@ -15,7 +23,7 @@ export declare class AuthenticatedGitOps implements IGitOps {
15
23
  private readonly retries;
16
24
  private readonly auth?;
17
25
  private readonly log?;
18
- constructor(localOps: ILocalGitOps, executor: ICommandExecutor, workDir: string, retries: number, auth?: GitAuthOptions | undefined, log?: DebugLog | undefined);
26
+ constructor(options: AuthenticatedGitOpsOptions);
19
27
  private execWithRetry;
20
28
  /**
21
29
  * Build the authenticated remote URL.
@@ -2,13 +2,6 @@ import { escapeShellArg } from "../shared/shell-utils.js";
2
2
  import { withRetry } from "../shared/retry-utils.js";
3
3
  import { toErrorMessage } from "../shared/type-guards.js";
4
4
  import { SyncError } from "../shared/errors.js";
5
- /**
6
- * Adds authentication to network git operations and delegates local ops.
7
- *
8
- * When auth options are provided, clone uses an embedded token URL which sets
9
- * the remote origin. Subsequent operations (fetch, push, getDefaultBranch)
10
- * reuse that authenticated remote URL — no extra auth setup per operation.
11
- */
12
5
  export class AuthenticatedGitOps {
13
6
  localOps;
14
7
  executor;
@@ -16,13 +9,13 @@ export class AuthenticatedGitOps {
16
9
  retries;
17
10
  auth;
18
11
  log;
19
- constructor(localOps, executor, workDir, retries, auth, log) {
20
- this.localOps = localOps;
21
- this.executor = executor;
22
- this.workDir = workDir;
23
- this.retries = retries;
24
- this.auth = auth;
25
- this.log = log;
12
+ constructor(options) {
13
+ this.localOps = options.localOps;
14
+ this.executor = options.executor;
15
+ this.workDir = options.workDir;
16
+ this.retries = options.retries;
17
+ this.auth = options.auth;
18
+ this.log = options.log;
26
19
  }
27
20
  async execWithRetry(command) {
28
21
  return withRetry(() => this.executor.exec(command, this.workDir), {
@@ -33,10 +33,15 @@ export class GitOps {
33
33
  return filePath;
34
34
  }
35
35
  cleanWorkspace() {
36
- if (existsSync(this._workDir)) {
37
- rmSync(this._workDir, { recursive: true, force: true });
36
+ try {
37
+ if (existsSync(this._workDir)) {
38
+ rmSync(this._workDir, { recursive: true, force: true });
39
+ }
40
+ mkdirSync(this._workDir, { recursive: true });
41
+ }
42
+ catch (error) {
43
+ throw new SyncError(`Failed to clean workspace '${this._workDir}': ${toErrorMessage(error)}`);
38
44
  }
39
- mkdirSync(this._workDir, { recursive: true });
40
45
  }
41
46
  /**
42
47
  * Create a new branch from the current HEAD.
@@ -57,11 +62,14 @@ export class GitOps {
57
62
  return;
58
63
  }
59
64
  const filePath = this.validatePath(fileName);
60
- // Create parent directories if they don't exist
61
- mkdirSync(dirname(filePath), { recursive: true });
62
- // Normalize trailing newline - ensure exactly one
63
- const normalized = content.endsWith("\n") ? content : content + "\n";
64
- writeFileSync(filePath, normalized, "utf-8");
65
+ try {
66
+ mkdirSync(dirname(filePath), { recursive: true });
67
+ const normalized = content.endsWith("\n") ? content : content + "\n";
68
+ writeFileSync(filePath, normalized, "utf-8");
69
+ }
70
+ catch (error) {
71
+ throw new SyncError(`Failed to write file '${fileName}': ${toErrorMessage(error)}`);
72
+ }
65
73
  }
66
74
  /**
67
75
  * Marks a file as executable both on the filesystem and in git's index.
@@ -74,8 +82,12 @@ export class GitOps {
74
82
  return;
75
83
  }
76
84
  const filePath = this.validatePath(fileName);
77
- // Set filesystem permissions (755 = rwxr-xr-x)
78
- chmodSync(filePath, 0o755);
85
+ try {
86
+ chmodSync(filePath, 0o755);
87
+ }
88
+ catch (error) {
89
+ throw new SyncError(`Failed to set executable permissions on '${fileName}': ${toErrorMessage(error)}`);
90
+ }
79
91
  // Also update git's index so the executable bit is committed
80
92
  const relativePath = relative(this._workDir, filePath);
81
93
  await this.exec(`git update-index --add --chmod=+x ${escapeShellArg(relativePath)}`, this._workDir);
@@ -97,7 +109,7 @@ export class GitOps {
97
109
  if (code === "ENOENT" || code === "EACCES") {
98
110
  return null;
99
111
  }
100
- this.log?.debug(`Unexpected error reading ${fileName}: ${error.message}`);
112
+ this.log?.debug(`Unexpected error reading ${fileName}: ${toErrorMessage(error)}`);
101
113
  return null;
102
114
  }
103
115
  }
@@ -180,7 +192,12 @@ export class GitOps {
180
192
  if (!existsSync(filePath)) {
181
193
  return;
182
194
  }
183
- rmSync(filePath);
195
+ try {
196
+ rmSync(filePath);
197
+ }
198
+ catch (error) {
199
+ throw new SyncError(`Failed to delete file '${fileName}': ${toErrorMessage(error)}`);
200
+ }
184
201
  }
185
202
  /**
186
203
  * Stage all changes and commit with the given message.
@@ -87,7 +87,12 @@ export class GitHubPRStrategy extends BasePRStrategy {
87
87
  const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, token, labels, } = options;
88
88
  assertGitHubRepo(repoInfo, "GitHub PR strategy");
89
89
  const bodyFile = join(workDir, this.bodyFilePath);
90
- writeFileSync(bodyFile, body, "utf-8");
90
+ try {
91
+ writeFileSync(bodyFile, body, "utf-8");
92
+ }
93
+ catch (err) {
94
+ throw new SyncError(`Failed to write PR description to ${bodyFile}: ${toErrorMessage(err)}`);
95
+ }
91
96
  const tokenEnv = buildTokenEnv(token);
92
97
  let command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
93
98
  // Append label flags
@@ -5,7 +5,7 @@ import { assertGitLabRepo } from "../shared/repo-detector.js";
5
5
  import { BasePRStrategy } from "./pr-strategy.js";
6
6
  import { withRetry, isPermanentError } from "../shared/retry-utils.js";
7
7
  import { getStderr } from "../shared/command-executor.js";
8
- import { parseApiJson } from "../shared/gh-api-utils.js";
8
+ import { parseApiJson } from "../shared/json-utils.js";
9
9
  import { sanitizeCredentials } from "../shared/sanitize-utils.js";
10
10
  import { toErrorMessage, safeCleanup } from "../shared/type-guards.js";
11
11
  import { NO_OP_DEBUG_LOG } from "../shared/logger.js";
@@ -146,7 +146,12 @@ export class GitLabPRStrategy extends BasePRStrategy {
146
146
  assertGitLabRepo(repoInfo, "GitLab PR strategy");
147
147
  const repoFlag = this.getRepoFlag(repoInfo);
148
148
  const descFile = join(workDir, this.bodyFilePath);
149
- writeFileSync(descFile, body, "utf-8");
149
+ try {
150
+ writeFileSync(descFile, body, "utf-8");
151
+ }
152
+ catch (err) {
153
+ throw new SyncError(`Failed to write PR description to ${descFile}: ${toErrorMessage(err)}`);
154
+ }
150
155
  // glab mr create with description from file
151
156
  const command = `glab mr create --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --title ${escapeShellArg(title)} --description "$(cat ${escapeShellArg(descFile)})" --yes -R ${escapeShellArg(repoFlag)}`;
152
157
  try {
@@ -2,7 +2,8 @@ import { isGitHubRepo } from "../shared/repo-detector.js";
2
2
  import { escapeShellArg } from "../shared/shell-utils.js";
3
3
  import { withRetry, CORE_PERMANENT_ERROR_PATTERNS, DEFAULT_PERMANENT_ERROR_PATTERNS, } from "../shared/retry-utils.js";
4
4
  import { toErrorMessage } from "../shared/type-guards.js";
5
- import { parseApiJson, buildTokenEnv } from "../shared/gh-api-utils.js";
5
+ import { parseApiJson } from "../shared/json-utils.js";
6
+ import { buildTokenEnv } from "../shared/gh-api-utils.js";
6
7
  import { ValidationError, GraphQLApiError } from "../shared/errors.js";
7
8
  /**
8
9
  * Maximum payload size for GitHub GraphQL API (50MB).
@@ -2,3 +2,4 @@ export type { PRMergeConfig, FileChange, FileAction, IGitOps, ILocalGitOps, INet
2
2
  export type { GitOpsOptions } from "./git-ops.js";
3
3
  export { getCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
4
4
  export { getPRStrategy } from "./pr-strategy-factory.js";
5
+ export { createPR, mergePR } from "./pr-creator.js";
package/dist/vcs/index.js CHANGED
@@ -2,3 +2,5 @@
2
2
  export { getCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
3
3
  // PR strategy factory
4
4
  export { getPRStrategy } from "./pr-strategy-factory.js";
5
+ // PR creation and merge
6
+ export { createPR, mergePR } from "./pr-creator.js";