@aspruyt/json-config-sync 3.10.0 → 3.10.3

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.
package/README.md CHANGED
@@ -540,7 +540,7 @@ repos:
540
540
 
541
541
  ### Executable Files
542
542
 
543
- Shell scripts (`.sh` files) are automatically marked as executable using `git update-index --chmod=+x`. You can control this behavior:
543
+ Shell scripts (`.sh` files) are automatically marked as executable using `git update-index --add --chmod=+x`. You can control this behavior:
544
544
 
545
545
  ```yaml
546
546
  files:
@@ -18,6 +18,10 @@ export class ShellCommandExecutor {
18
18
  if (execError.stderr && typeof execError.stderr !== "string") {
19
19
  execError.stderr = execError.stderr.toString();
20
20
  }
21
+ // Include stderr in error message for better debugging
22
+ if (execError.stderr && execError.message) {
23
+ execError.message = `${execError.message}\n${execError.stderr}`;
24
+ }
21
25
  throw error;
22
26
  }
23
27
  }
package/dist/git-ops.d.ts CHANGED
@@ -26,6 +26,11 @@ export declare class GitOps {
26
26
  private validatePath;
27
27
  cleanWorkspace(): void;
28
28
  clone(gitUrl: string): Promise<void>;
29
+ /**
30
+ * Create a new branch from the current HEAD.
31
+ * Always creates fresh - existing branches should be cleaned up beforehand
32
+ * by closing any existing PRs (which deletes the remote branch).
33
+ */
29
34
  createBranch(branchName: string): Promise<void>;
30
35
  writeFile(fileName: string, content: string): void;
31
36
  /**
@@ -40,7 +45,22 @@ export declare class GitOps {
40
45
  */
41
46
  wouldChange(fileName: string, content: string): boolean;
42
47
  hasChanges(): Promise<boolean>;
43
- commit(message: string): Promise<void>;
48
+ /**
49
+ * Check if there are staged changes ready to commit.
50
+ * Uses `git diff --cached --quiet` which exits with 1 if there are staged changes.
51
+ */
52
+ hasStagedChanges(): Promise<boolean>;
53
+ /**
54
+ * Check if a file exists on a specific branch.
55
+ * Used for createOnly checks against the base branch (not the working directory).
56
+ */
57
+ fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
58
+ /**
59
+ * Stage all changes and commit with the given message.
60
+ * Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
61
+ * @returns true if a commit was made, false if there were no staged changes
62
+ */
63
+ commit(message: string): Promise<boolean>;
44
64
  push(branchName: string): Promise<void>;
45
65
  getDefaultBranch(): Promise<{
46
66
  branch: string;
package/dist/git-ops.js CHANGED
@@ -4,21 +4,6 @@ import { escapeShellArg } from "./shell-utils.js";
4
4
  import { defaultExecutor } from "./command-executor.js";
5
5
  import { withRetry } from "./retry-utils.js";
6
6
  import { logger } from "./logger.js";
7
- /**
8
- * Patterns indicating a git branch does not exist.
9
- * Used to distinguish "branch not found" from other errors.
10
- */
11
- const BRANCH_NOT_FOUND_PATTERNS = [
12
- "couldn't find remote ref",
13
- "pathspec",
14
- "did not match any",
15
- ];
16
- /**
17
- * Checks if an error message indicates a branch was not found.
18
- */
19
- function isBranchNotFoundError(message) {
20
- return BRANCH_NOT_FOUND_PATTERNS.some((pattern) => message.includes(pattern));
21
- }
22
7
  export class GitOps {
23
8
  workDir;
24
9
  dryRun;
@@ -66,24 +51,12 @@ export class GitOps {
66
51
  async clone(gitUrl) {
67
52
  await this.execWithRetry(`git clone ${escapeShellArg(gitUrl)} .`, this.workDir);
68
53
  }
54
+ /**
55
+ * Create a new branch from the current HEAD.
56
+ * Always creates fresh - existing branches should be cleaned up beforehand
57
+ * by closing any existing PRs (which deletes the remote branch).
58
+ */
69
59
  async createBranch(branchName) {
70
- try {
71
- // Check if branch exists on remote (network operation with retry)
72
- await this.execWithRetry(`git fetch origin ${escapeShellArg(branchName)}`, this.workDir);
73
- // Ensure clean workspace before checkout (defensive - handles edge cases)
74
- await this.exec("git reset --hard HEAD", this.workDir);
75
- await this.exec("git clean -fd", this.workDir);
76
- await this.execWithRetry(`git checkout ${escapeShellArg(branchName)}`, this.workDir);
77
- return;
78
- }
79
- catch (error) {
80
- // Only proceed to create branch if error indicates branch doesn't exist
81
- const message = error instanceof Error ? error.message : String(error);
82
- if (!isBranchNotFoundError(message)) {
83
- throw new Error(`Failed to fetch/checkout branch '${branchName}': ${message}`);
84
- }
85
- }
86
- // Branch doesn't exist on remote, create it locally
87
60
  try {
88
61
  await this.exec(`git checkout -b ${escapeShellArg(branchName)}`, this.workDir);
89
62
  }
@@ -115,7 +88,7 @@ export class GitOps {
115
88
  const filePath = this.validatePath(fileName);
116
89
  // Use relative path from workDir for git command
117
90
  const relativePath = relative(this.workDir, filePath);
118
- await this.exec(`git update-index --chmod=+x ${escapeShellArg(relativePath)}`, this.workDir);
91
+ await this.exec(`git update-index --add --chmod=+x ${escapeShellArg(relativePath)}`, this.workDir);
119
92
  }
120
93
  /**
121
94
  * Checks if writing the given content would result in changes.
@@ -142,12 +115,49 @@ export class GitOps {
142
115
  const status = await this.exec("git status --porcelain", this.workDir);
143
116
  return status.length > 0;
144
117
  }
118
+ /**
119
+ * Check if there are staged changes ready to commit.
120
+ * Uses `git diff --cached --quiet` which exits with 1 if there are staged changes.
121
+ */
122
+ async hasStagedChanges() {
123
+ try {
124
+ await this.exec("git diff --cached --quiet", this.workDir);
125
+ return false; // Exit code 0 = no staged changes
126
+ }
127
+ catch {
128
+ return true; // Exit code 1 = there are staged changes
129
+ }
130
+ }
131
+ /**
132
+ * Check if a file exists on a specific branch.
133
+ * Used for createOnly checks against the base branch (not the working directory).
134
+ */
135
+ async fileExistsOnBranch(fileName, branch) {
136
+ try {
137
+ await this.exec(`git show ${escapeShellArg(branch)}:${escapeShellArg(fileName)}`, this.workDir);
138
+ return true;
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ /**
145
+ * Stage all changes and commit with the given message.
146
+ * Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
147
+ * @returns true if a commit was made, false if there were no staged changes
148
+ */
145
149
  async commit(message) {
146
150
  if (this.dryRun) {
147
- return;
151
+ return true;
148
152
  }
149
153
  await this.exec("git add -A", this.workDir);
150
- await this.exec(`git commit -m ${escapeShellArg(message)}`, this.workDir);
154
+ // Check if there are actually staged changes after git add
155
+ if (!(await this.hasStagedChanges())) {
156
+ return false; // No changes to commit
157
+ }
158
+ // Use --no-verify to skip pre-commit hooks
159
+ await this.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, this.workDir);
160
+ return true;
151
161
  }
152
162
  async push(branchName) {
153
163
  if (this.dryRun) {
@@ -2,12 +2,15 @@ import { RepoConfig } from "./config.js";
2
2
  import { RepoInfo } from "./repo-detector.js";
3
3
  import { GitOps, GitOpsOptions } from "./git-ops.js";
4
4
  import { ILogger } from "./logger.js";
5
+ import { CommandExecutor } from "./command-executor.js";
5
6
  export interface ProcessorOptions {
6
7
  branchName: string;
7
8
  workDir: string;
8
9
  dryRun?: boolean;
9
10
  /** Number of retries for network operations (default: 3) */
10
11
  retries?: number;
12
+ /** Command executor for shell commands (for testing) */
13
+ executor?: CommandExecutor;
11
14
  }
12
15
  /**
13
16
  * Factory function type for creating GitOps instances.
@@ -5,6 +5,8 @@ import { getRepoDisplayName } from "./repo-detector.js";
5
5
  import { GitOps } from "./git-ops.js";
6
6
  import { createPR, mergePR } from "./pr-creator.js";
7
7
  import { logger } from "./logger.js";
8
+ import { getPRStrategy } from "./strategies/index.js";
9
+ import { defaultExecutor } from "./command-executor.js";
8
10
  /**
9
11
  * Determines if a file should be marked as executable.
10
12
  * .sh files are auto-executable unless explicit executable: false is set.
@@ -35,6 +37,7 @@ export class RepositoryProcessor {
35
37
  async process(repoConfig, repoInfo, options) {
36
38
  const repoName = getRepoDisplayName(repoInfo);
37
39
  const { branchName, workDir, dryRun, retries } = options;
40
+ const executor = options.executor ?? defaultExecutor;
38
41
  this.gitOps = this.gitOpsFactory({ workDir, dryRun, retries });
39
42
  try {
40
43
  // Step 1: Clean workspace
@@ -46,19 +49,39 @@ export class RepositoryProcessor {
46
49
  // Step 3: Get default branch for PR base
47
50
  const { branch: baseBranch, method: detectionMethod } = await this.gitOps.getDefaultBranch();
48
51
  this.log.info(`Default branch: ${baseBranch} (detected via ${detectionMethod})`);
49
- // Step 4: Create/checkout branch
50
- this.log.info(`Switching to branch: ${branchName}`);
52
+ // Step 3.5: Close existing PR if exists (fresh start approach)
53
+ // This ensures isolated sync attempts - each run starts from clean state
54
+ if (!dryRun) {
55
+ this.log.info("Checking for existing PR...");
56
+ const strategy = getPRStrategy(repoInfo, executor);
57
+ const closed = await strategy.closeExistingPR({
58
+ repoInfo,
59
+ branchName,
60
+ baseBranch,
61
+ workDir,
62
+ retries,
63
+ });
64
+ if (closed) {
65
+ this.log.info("Closed existing PR and deleted branch for fresh sync");
66
+ }
67
+ }
68
+ // Step 4: Create branch (always fresh from base branch)
69
+ this.log.info(`Creating branch: ${branchName}`);
51
70
  await this.gitOps.createBranch(branchName);
52
71
  // Step 5: Write all config files and track changes
53
72
  const changedFiles = [];
54
73
  for (const file of repoConfig.files) {
55
74
  const filePath = join(workDir, file.fileName);
56
- const fileExists = existsSync(filePath);
57
- // Handle createOnly - skip if file already exists
58
- if (file.createOnly && fileExists) {
59
- this.log.info(`Skipping ${file.fileName} (createOnly: already exists)`);
60
- changedFiles.push({ fileName: file.fileName, action: "skip" });
61
- continue;
75
+ const fileExistsLocal = existsSync(filePath);
76
+ // Handle createOnly - check against BASE branch, not current working directory
77
+ // This ensures consistent behavior: createOnly means "only create if doesn't exist on main"
78
+ if (file.createOnly) {
79
+ const existsOnBase = await this.gitOps.fileExistsOnBranch(file.fileName, baseBranch);
80
+ if (existsOnBase) {
81
+ this.log.info(`Skipping ${file.fileName} (createOnly: exists on ${baseBranch})`);
82
+ changedFiles.push({ fileName: file.fileName, action: "skip" });
83
+ continue;
84
+ }
62
85
  }
63
86
  this.log.info(`Writing ${file.fileName}...`);
64
87
  const fileContent = convertContentToString(file.content, file.fileName, {
@@ -66,7 +89,9 @@ export class RepositoryProcessor {
66
89
  schemaUrl: file.schemaUrl,
67
90
  });
68
91
  // Determine action type (create vs update)
69
- const action = fileExists ? "update" : "create";
92
+ const action = fileExistsLocal
93
+ ? "update"
94
+ : "create";
70
95
  if (dryRun) {
71
96
  // In dry-run, check if file would change without writing
72
97
  if (this.gitOps.wouldChange(file.fileName, fileContent)) {
@@ -125,9 +150,19 @@ export class RepositoryProcessor {
125
150
  };
126
151
  }
127
152
  // Step 7: Commit
128
- this.log.info("Committing changes...");
153
+ this.log.info("Staging changes...");
129
154
  const commitMessage = this.formatCommitMessage(changedFiles);
130
- await this.gitOps.commit(commitMessage);
155
+ const committed = await this.gitOps.commit(commitMessage);
156
+ if (!committed) {
157
+ this.log.info("No staged changes after git add -A, skipping commit");
158
+ return {
159
+ success: true,
160
+ repoName,
161
+ message: "No changes detected after staging",
162
+ skipped: true,
163
+ };
164
+ }
165
+ this.log.info(`Committed: ${commitMessage}`);
131
166
  // Step 8: Push
132
167
  this.log.info("Pushing to remote...");
133
168
  await this.gitOps.push(branchName);
@@ -1,11 +1,12 @@
1
1
  import { PRResult } from "../pr-creator.js";
2
- import { BasePRStrategy, PRStrategyOptions, MergeOptions, MergeResult } from "./pr-strategy.js";
2
+ import { BasePRStrategy, PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./pr-strategy.js";
3
3
  import { CommandExecutor } from "../command-executor.js";
4
4
  export declare class AzurePRStrategy extends BasePRStrategy {
5
5
  constructor(executor?: CommandExecutor);
6
6
  private getOrgUrl;
7
7
  private buildPRUrl;
8
8
  checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
9
+ closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
9
10
  create(options: PRStrategyOptions): Promise<PRResult>;
10
11
  /**
11
12
  * Extract PR ID and repo info from Azure DevOps PR URL.
@@ -41,6 +41,58 @@ export class AzurePRStrategy extends BasePRStrategy {
41
41
  return null;
42
42
  }
43
43
  }
44
+ async closeExistingPR(options) {
45
+ const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
46
+ if (!isAzureDevOpsRepo(repoInfo)) {
47
+ throw new Error("Expected Azure DevOps repository");
48
+ }
49
+ const azureRepoInfo = repoInfo;
50
+ const orgUrl = this.getOrgUrl(azureRepoInfo);
51
+ // First check if there's an existing PR
52
+ const existingUrl = await this.checkExistingPR({
53
+ repoInfo,
54
+ branchName,
55
+ baseBranch,
56
+ workDir,
57
+ retries,
58
+ title: "", // Not used for check
59
+ body: "", // Not used for check
60
+ });
61
+ if (!existingUrl) {
62
+ return false;
63
+ }
64
+ // Extract PR ID from URL
65
+ const prInfo = this.parsePRUrl(existingUrl);
66
+ if (!prInfo) {
67
+ logger.info(`Warning: Could not parse PR URL: ${existingUrl}`);
68
+ return false;
69
+ }
70
+ // Abandon the PR (Azure DevOps equivalent of closing)
71
+ const abandonCommand = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --status abandoned --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(prInfo.project)}`;
72
+ try {
73
+ await withRetry(() => this.executor.exec(abandonCommand, workDir), {
74
+ retries,
75
+ });
76
+ }
77
+ catch (error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ logger.info(`Warning: Failed to abandon PR #${prInfo.prId}: ${message}`);
80
+ return false;
81
+ }
82
+ // Delete the source branch
83
+ const deleteBranchCommand = `az repos ref delete --name refs/heads/${escapeShellArg(branchName)} --repository ${escapeShellArg(azureRepoInfo.repo)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)}`;
84
+ try {
85
+ await withRetry(() => this.executor.exec(deleteBranchCommand, workDir), {
86
+ retries,
87
+ });
88
+ }
89
+ catch (error) {
90
+ // Branch deletion failure is not critical - PR is already abandoned
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ logger.info(`Warning: Failed to delete branch ${branchName}: ${message}`);
93
+ }
94
+ return true;
95
+ }
44
96
  async create(options) {
45
97
  const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
46
98
  if (!isAzureDevOpsRepo(repoInfo)) {
@@ -1,8 +1,9 @@
1
1
  import { GitHubRepoInfo } from "../repo-detector.js";
2
2
  import { PRResult } from "../pr-creator.js";
3
- import { BasePRStrategy, PRStrategyOptions, MergeOptions, MergeResult } from "./pr-strategy.js";
3
+ import { BasePRStrategy, PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./pr-strategy.js";
4
4
  export declare class GitHubPRStrategy extends BasePRStrategy {
5
5
  checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
6
+ closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
6
7
  create(options: PRStrategyOptions): Promise<PRResult>;
7
8
  /**
8
9
  * Check if auto-merge is enabled on the repository.
@@ -31,6 +31,42 @@ export class GitHubPRStrategy extends BasePRStrategy {
31
31
  return null;
32
32
  }
33
33
  }
34
+ async closeExistingPR(options) {
35
+ const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
36
+ if (!isGitHubRepo(repoInfo)) {
37
+ throw new Error("Expected GitHub repository");
38
+ }
39
+ // First check if there's an existing PR
40
+ const existingUrl = await this.checkExistingPR({
41
+ repoInfo,
42
+ branchName,
43
+ baseBranch,
44
+ workDir,
45
+ retries,
46
+ title: "", // Not used for check
47
+ body: "", // Not used for check
48
+ });
49
+ if (!existingUrl) {
50
+ return false;
51
+ }
52
+ // Extract PR number from URL
53
+ const prNumber = existingUrl.match(/\/pull\/(\d+)/)?.[1];
54
+ if (!prNumber) {
55
+ logger.info(`Warning: Could not extract PR number from URL: ${existingUrl}`);
56
+ return false;
57
+ }
58
+ // Close the PR and delete the branch
59
+ const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --delete-branch`;
60
+ try {
61
+ await withRetry(() => this.executor.exec(command, workDir), { retries });
62
+ return true;
63
+ }
64
+ catch (error) {
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ logger.info(`Warning: Failed to close existing PR #${prNumber}: ${message}`);
67
+ return false;
68
+ }
69
+ }
34
70
  async create(options) {
35
71
  const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
36
72
  if (!isGitHubRepo(repoInfo)) {
@@ -1,12 +1,13 @@
1
1
  import { RepoInfo } from "../repo-detector.js";
2
2
  import type { PRStrategy } from "./pr-strategy.js";
3
- export type { PRStrategy, PRStrategyOptions, PRMergeConfig, MergeOptions, MergeResult, } from "./pr-strategy.js";
3
+ import { CommandExecutor } from "../command-executor.js";
4
+ export type { PRStrategy, PRStrategyOptions, CloseExistingPROptions, PRMergeConfig, MergeOptions, MergeResult, } from "./pr-strategy.js";
4
5
  export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
5
6
  export { GitHubPRStrategy } from "./github-pr-strategy.js";
6
7
  export { AzurePRStrategy } from "./azure-pr-strategy.js";
7
8
  /**
8
9
  * Factory function to get the appropriate PR strategy for a repository.
9
- * Note: repoInfo is passed via PRStrategyOptions.execute() rather than constructor
10
- * to ensure LSP compliance (all strategies have identical constructors).
10
+ * @param repoInfo - Repository information
11
+ * @param executor - Optional command executor for shell commands
11
12
  */
12
- export declare function getPRStrategy(repoInfo: RepoInfo): PRStrategy;
13
+ export declare function getPRStrategy(repoInfo: RepoInfo, executor?: CommandExecutor): PRStrategy;
@@ -6,15 +6,15 @@ export { GitHubPRStrategy } from "./github-pr-strategy.js";
6
6
  export { AzurePRStrategy } from "./azure-pr-strategy.js";
7
7
  /**
8
8
  * Factory function to get the appropriate PR strategy for a repository.
9
- * Note: repoInfo is passed via PRStrategyOptions.execute() rather than constructor
10
- * to ensure LSP compliance (all strategies have identical constructors).
9
+ * @param repoInfo - Repository information
10
+ * @param executor - Optional command executor for shell commands
11
11
  */
12
- export function getPRStrategy(repoInfo) {
12
+ export function getPRStrategy(repoInfo, executor) {
13
13
  if (isGitHubRepo(repoInfo)) {
14
- return new GitHubPRStrategy();
14
+ return new GitHubPRStrategy(executor);
15
15
  }
16
16
  if (isAzureDevOpsRepo(repoInfo)) {
17
- return new AzurePRStrategy();
17
+ return new AzurePRStrategy(executor);
18
18
  }
19
19
  // Type exhaustiveness check - should never reach here
20
20
  const _exhaustive = repoInfo;
@@ -30,6 +30,16 @@ export interface MergeOptions {
30
30
  workDir: string;
31
31
  retries?: number;
32
32
  }
33
+ /**
34
+ * Options for closing an existing PR.
35
+ */
36
+ export interface CloseExistingPROptions {
37
+ repoInfo: RepoInfo;
38
+ branchName: string;
39
+ baseBranch: string;
40
+ workDir: string;
41
+ retries?: number;
42
+ }
33
43
  /**
34
44
  * Interface for PR creation strategies (platform-specific implementations).
35
45
  * Strategies focus on platform-specific logic (checkExistingPR, create, merge).
@@ -41,6 +51,12 @@ export interface PRStrategy {
41
51
  * @returns PR URL if exists, null otherwise
42
52
  */
43
53
  checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
54
+ /**
55
+ * Close an existing PR and delete its branch.
56
+ * Used for fresh start approach - always create new PR from clean state.
57
+ * @returns true if PR was closed, false if no PR existed
58
+ */
59
+ closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
44
60
  /**
45
61
  * Create a new PR
46
62
  * @returns Result with URL and status
@@ -62,6 +78,7 @@ export declare abstract class BasePRStrategy implements PRStrategy {
62
78
  protected executor: CommandExecutor;
63
79
  constructor(executor?: CommandExecutor);
64
80
  abstract checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
81
+ abstract closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
65
82
  abstract create(options: PRStrategyOptions): Promise<PRResult>;
66
83
  abstract merge(options: MergeOptions): Promise<MergeResult>;
67
84
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "3.10.0",
3
+ "version": "3.10.3",
4
4
  "description": "CLI tool to sync JSON or YAML configuration files across multiple GitHub and Azure DevOps repositories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",