@aspruyt/xfg 1.0.3 → 1.1.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.
package/README.md CHANGED
@@ -136,16 +136,16 @@ xfg --config ./config.yaml --branch feature/update-eslint
136
136
 
137
137
  ### Options
138
138
 
139
- | Option | Alias | Description | Required |
140
- | ------------------ | ----- | ------------------------------------------------------------------------------------ | -------- |
141
- | `--config` | `-c` | Path to YAML config file | Yes |
142
- | `--dry-run` | `-d` | Show what would be done without making changes | No |
143
- | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
144
- | `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
145
- | `--branch` | `-b` | Override branch name (default: `chore/sync-config`) | No |
146
- | `--merge` | `-m` | PR merge mode: `manual` (default), `auto` (merge when checks pass), `force` (bypass) | No |
147
- | `--merge-strategy` | | Merge strategy: `merge` (default), `squash`, `rebase` | No |
148
- | `--delete-branch` | | Delete source branch after merge | No |
139
+ | Option | Alias | Description | Required |
140
+ | ------------------ | ----- | ------------------------------------------------------------------------------ | -------- |
141
+ | `--config` | `-c` | Path to YAML config file | Yes |
142
+ | `--dry-run` | `-d` | Show what would be done without making changes | No |
143
+ | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
144
+ | `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
145
+ | `--branch` | `-b` | Override branch name (default: `chore/sync-{filename}` or `chore/sync-config`) | No |
146
+ | `--merge` | `-m` | PR merge mode: `manual`, `auto` (default), `force` (bypass checks) | No |
147
+ | `--merge-strategy` | | Merge strategy: `merge`, `squash` (default), `rebase` | No |
148
+ | `--delete-branch` | | Delete source branch after merge | No |
149
149
 
150
150
  ## Configuration Format
151
151
 
@@ -197,9 +197,9 @@ repos: # List of repositories
197
197
 
198
198
  | Field | Description | Default |
199
199
  | --------------- | --------------------------------------------------------------------------- | -------- |
200
- | `merge` | Merge mode: `manual` (leave open), `auto` (merge when checks pass), `force` | `manual` |
201
- | `mergeStrategy` | How to merge: `merge`, `squash`, `rebase` | `merge` |
202
- | `deleteBranch` | Delete source branch after merge | `false` |
200
+ | `merge` | Merge mode: `manual` (leave open), `auto` (merge when checks pass), `force` | `auto` |
201
+ | `mergeStrategy` | How to merge: `merge`, `squash`, `rebase` | `squash` |
202
+ | `deleteBranch` | Delete source branch after merge | `true` |
203
203
  | `bypassReason` | Reason for bypassing policies (Azure DevOps only, required for `force`) | - |
204
204
 
205
205
  ### Per-Repo File Override Fields
@@ -649,7 +649,7 @@ config/
649
649
 
650
650
  ### Auto-Merge PRs
651
651
 
652
- Configure PRs to merge automatically when checks pass, or force merge using admin privileges:
652
+ By default, xfg enables auto-merge on PRs when checks pass. You can override this behavior per-repo or via CLI flags:
653
653
 
654
654
  ```yaml
655
655
  files:
@@ -658,18 +658,18 @@ files:
658
658
  semi: false
659
659
  singleQuote: true
660
660
 
661
- # Global PR options - apply to all repos
662
- prOptions:
663
- merge: auto # auto-merge when checks pass
664
- mergeStrategy: squash # squash commits
665
- deleteBranch: true # cleanup after merge
666
-
661
+ # Default behavior (auto-merge with squash, delete branch) - no prOptions needed
667
662
  repos:
668
- # These repos use global prOptions (auto-merge)
663
+ # These repos use defaults (auto-merge with squash, delete branch)
669
664
  - git:
670
665
  - git@github.com:org/frontend.git
671
666
  - git@github.com:org/backend.git
672
667
 
668
+ # This repo overrides to manual (leave PR open for review)
669
+ - git: git@github.com:org/needs-review.git
670
+ prOptions:
671
+ merge: manual
672
+
673
673
  # This repo overrides to force merge (bypass required reviews)
674
674
  - git: git@github.com:org/internal-tool.git
675
675
  prOptions:
@@ -679,11 +679,11 @@ repos:
679
679
 
680
680
  **Merge Modes:**
681
681
 
682
- | Mode | GitHub Behavior | Azure DevOps Behavior |
683
- | -------- | --------------------------------------- | -------------------------------------- |
684
- | `manual` | Leave PR open for review (default) | Leave PR open for review |
685
- | `auto` | Enable auto-merge (requires repo setup) | Enable auto-complete |
686
- | `force` | Merge with `--admin` (bypass checks) | Bypass policies with `--bypass-policy` |
682
+ | Mode | GitHub Behavior | Azure DevOps Behavior |
683
+ | -------- | ---------------------------------------------------- | -------------------------------------- |
684
+ | `manual` | Leave PR open for review | Leave PR open for review |
685
+ | `auto` | Enable auto-merge (requires repo setup, **default**) | Enable auto-complete (**default**) |
686
+ | `force` | Merge with `--admin` (bypass checks) | Bypass policies with `--bypass-policy` |
687
687
 
688
688
  **GitHub Auto-Merge Note:** The `auto` mode requires auto-merge to be enabled in the repository settings. If not enabled, the tool will warn and leave the PR open for manual review. Enable it with:
689
689
 
@@ -694,11 +694,11 @@ gh repo edit org/repo --enable-auto-merge
694
694
  **CLI Override:** You can override config file settings with CLI flags:
695
695
 
696
696
  ```bash
697
+ # Disable auto-merge, leave PRs open for review
698
+ xfg --config ./config.yaml --merge manual
699
+
697
700
  # Force merge all PRs (useful for urgent updates)
698
701
  xfg --config ./config.yaml --merge force
699
-
700
- # Enable auto-merge with squash
701
- xfg --config ./config.yaml --merge auto --merge-strategy squash --delete-branch
702
702
  ```
703
703
 
704
704
  ## Supported Git URL Formats
@@ -727,23 +727,28 @@ flowchart TB
727
727
  end
728
728
 
729
729
  subgraph Processing["For Each Repository"]
730
- CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-config]
731
- BRANCH --> WRITE[Write All Config Files<br/>JSON, JSON5, or YAML]
730
+ CLONE[Clone Repo] --> DETECT_BRANCH[Detect Default Branch]
731
+ DETECT_BRANCH --> CLOSE_PR[Close Existing PR<br/>if exists]
732
+ CLOSE_PR --> BRANCH[Create Fresh Branch]
733
+ BRANCH --> WRITE[Write Config Files]
732
734
  WRITE --> CHECK{Changes?}
733
735
  CHECK -->|No| SKIP[Skip - No Changes]
734
- CHECK -->|Yes| COMMIT[Commit Changes]
735
- COMMIT --> PUSH[Push to Remote]
736
+ CHECK -->|Yes| COMMIT[Commit & Push]
736
737
  end
737
738
 
738
- subgraph Platform["Platform Detection"]
739
- PUSH --> DETECT{GitHub or<br/>Azure DevOps?}
739
+ subgraph Platform["PR Creation"]
740
+ COMMIT --> PR_DETECT{GitHub or<br/>Azure DevOps?}
741
+ PR_DETECT -->|GitHub| GH_PR[Create PR via gh CLI]
742
+ PR_DETECT -->|Azure DevOps| AZ_PR[Create PR via az CLI]
743
+ GH_PR --> PR_CREATED[PR Created]
744
+ AZ_PR --> PR_CREATED
740
745
  end
741
746
 
742
- subgraph Output
743
- DETECT -->|GitHub| GH_PR[Create PR via gh CLI]
744
- DETECT -->|Azure DevOps| AZ_PR[Create PR via az CLI]
745
- GH_PR --> GH_URL[/"GitHub PR URL"/]
746
- AZ_PR --> AZ_URL[/"Azure DevOps PR URL"/]
747
+ subgraph AutoMerge["Auto-Merge (default)"]
748
+ PR_CREATED --> MERGE_MODE{Merge Mode?}
749
+ MERGE_MODE -->|manual| OPEN[Leave PR Open]
750
+ MERGE_MODE -->|auto| AUTO[Enable Auto-Merge]
751
+ MERGE_MODE -->|force| FORCE[Bypass & Merge]
747
752
  end
748
753
 
749
754
  YAML --> EXPAND
@@ -757,11 +762,14 @@ For each repository in the config, the tool:
757
762
  3. Interpolates environment variables
758
763
  4. Cleans the temporary workspace
759
764
  5. Clones the repository
760
- 6. Creates/checks out branch (custom `--branch` or default `chore/sync-config`)
761
- 7. Writes all config files (JSON, JSON5, or YAML based on filename extension)
762
- 8. Checks for changes (skips if no changes)
763
- 9. Commits and pushes changes
764
- 10. Creates a pull request
765
+ 6. Detects the default branch (main/master)
766
+ 7. Closes any existing PR on the branch and deletes the remote branch (fresh start)
767
+ 8. Creates a fresh branch from the default branch
768
+ 9. Writes all config files (JSON, JSON5, YAML, or text based on filename extension)
769
+ 10. Checks for changes (skips if no changes)
770
+ 11. Commits and pushes changes
771
+ 12. Creates a pull request
772
+ 13. Handles auto-merge based on configuration (auto by default)
765
773
 
766
774
  ## CI/CD Integration
767
775
 
@@ -890,10 +898,12 @@ az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG
890
898
 
891
899
  ### Branch Already Exists
892
900
 
893
- The tool automatically reuses existing branches. If you see unexpected behavior:
901
+ The tool uses a fresh-start approach: it closes any existing PR on the branch and deletes the branch before creating a new one. This ensures each sync attempt is isolated and avoids merge conflicts from stale branches.
902
+
903
+ If you see issues with stale branches:
894
904
 
895
905
  ```bash
896
- # Delete the remote branch to start fresh
906
+ # Manually delete the remote branch if needed
897
907
  git push origin --delete chore/sync-config
898
908
  ```
899
909
 
@@ -985,7 +995,3 @@ npm test
985
995
  # Build
986
996
  npm run build
987
997
  ```
988
-
989
- ## License
990
-
991
- MIT
@@ -93,7 +93,10 @@ export function normalizeConfig(raw) {
93
93
  else {
94
94
  // Merge mode: handle text vs object content
95
95
  if (isTextContent(fileConfig.content)) {
96
- // Text content merging
96
+ // Text content merging - validate overlay is also text
97
+ if (!isTextContent(repoOverride.content)) {
98
+ throw new Error(`Expected text content for ${fileName}, got object`);
99
+ }
97
100
  mergedContent = mergeTextContent(fileConfig.content, repoOverride.content, fileStrategy);
98
101
  }
99
102
  else {
@@ -1,4 +1,4 @@
1
- import { isAbsolute } from "node:path";
1
+ import { extname, isAbsolute } from "node:path";
2
2
  const VALID_STRATEGIES = ["replace", "append", "prepend"];
3
3
  /**
4
4
  * Check if content is text type (string or string[]).
@@ -18,8 +18,8 @@ function isObjectContent(content) {
18
18
  * Check if file extension is for structured output (JSON/YAML).
19
19
  */
20
20
  function isStructuredFileExtension(fileName) {
21
- const ext = fileName.toLowerCase().split(".").pop();
22
- return ext === "json" || ext === "json5" || ext === "yaml" || ext === "yml";
21
+ const ext = extname(fileName).toLowerCase();
22
+ return (ext === ".json" || ext === ".json5" || ext === ".yaml" || ext === ".yml");
23
23
  }
24
24
  /**
25
25
  * Validates raw config structure before normalization.
@@ -0,0 +1,31 @@
1
+ export type FileStatus = "NEW" | "MODIFIED" | "UNCHANGED";
2
+ /**
3
+ * Determines file status based on existence and change detection.
4
+ */
5
+ export declare function getFileStatus(exists: boolean, changed: boolean): FileStatus;
6
+ /**
7
+ * Format a status badge with appropriate color.
8
+ */
9
+ export declare function formatStatusBadge(status: FileStatus): string;
10
+ /**
11
+ * Format a single diff line with appropriate color.
12
+ */
13
+ export declare function formatDiffLine(line: string): string;
14
+ /**
15
+ * Generate a unified diff between old and new content.
16
+ * Returns an array of formatted diff lines.
17
+ */
18
+ export declare function generateDiff(oldContent: string | null, newContent: string, fileName: string, contextLines?: number): string[];
19
+ export interface DiffStats {
20
+ newCount: number;
21
+ modifiedCount: number;
22
+ unchangedCount: number;
23
+ }
24
+ /**
25
+ * Create an empty diff stats object.
26
+ */
27
+ export declare function createDiffStats(): DiffStats;
28
+ /**
29
+ * Increment the appropriate counter in diff stats.
30
+ */
31
+ export declare function incrementDiffStats(stats: DiffStats, status: FileStatus): void;
@@ -0,0 +1,223 @@
1
+ import chalk from "chalk";
2
+ /**
3
+ * Determines file status based on existence and change detection.
4
+ */
5
+ export function getFileStatus(exists, changed) {
6
+ if (!exists)
7
+ return "NEW";
8
+ return changed ? "MODIFIED" : "UNCHANGED";
9
+ }
10
+ /**
11
+ * Format a status badge with appropriate color.
12
+ */
13
+ export function formatStatusBadge(status) {
14
+ switch (status) {
15
+ case "NEW":
16
+ return chalk.green("[NEW]");
17
+ case "MODIFIED":
18
+ return chalk.yellow("[MODIFIED]");
19
+ case "UNCHANGED":
20
+ return chalk.gray("[UNCHANGED]");
21
+ }
22
+ }
23
+ /**
24
+ * Format a single diff line with appropriate color.
25
+ */
26
+ export function formatDiffLine(line) {
27
+ if (line.startsWith("+"))
28
+ return chalk.green(line);
29
+ if (line.startsWith("-"))
30
+ return chalk.red(line);
31
+ if (line.startsWith("@@"))
32
+ return chalk.cyan(line);
33
+ return line;
34
+ }
35
+ /**
36
+ * Generate a unified diff between old and new content.
37
+ * Returns an array of formatted diff lines.
38
+ */
39
+ export function generateDiff(oldContent, newContent, fileName, contextLines = 3) {
40
+ const oldLines = oldContent ? oldContent.split("\n") : [];
41
+ const newLines = newContent.split("\n");
42
+ // For new files, show all lines as additions
43
+ if (oldContent === null) {
44
+ const result = [];
45
+ for (const line of newLines) {
46
+ result.push(formatDiffLine(`+${line}`));
47
+ }
48
+ return result;
49
+ }
50
+ // Simple LCS-based diff algorithm
51
+ const hunks = computeDiffHunks(oldLines, newLines, contextLines);
52
+ if (hunks.length === 0) {
53
+ return [];
54
+ }
55
+ const result = [];
56
+ for (const hunk of hunks) {
57
+ result.push(formatDiffLine(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`));
58
+ for (const line of hunk.lines) {
59
+ result.push(formatDiffLine(line));
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+ /**
65
+ * Compute diff hunks using a simple line-by-line comparison.
66
+ * This is a simplified diff that shows changed regions with context.
67
+ */
68
+ function computeDiffHunks(oldLines, newLines, contextLines) {
69
+ // Compute edit script using LCS
70
+ const editScript = computeEditScript(oldLines, newLines);
71
+ if (editScript.length === 0) {
72
+ return [];
73
+ }
74
+ // Group edits into hunks with context
75
+ return groupIntoHunks(editScript, oldLines, newLines, contextLines);
76
+ }
77
+ /**
78
+ * Compute an edit script using a simple O(mn) LCS algorithm.
79
+ */
80
+ function computeEditScript(oldLines, newLines) {
81
+ const m = oldLines.length;
82
+ const n = newLines.length;
83
+ // Build LCS table
84
+ const lcs = Array(m + 1)
85
+ .fill(null)
86
+ .map(() => Array(n + 1).fill(0));
87
+ for (let i = 1; i <= m; i++) {
88
+ for (let j = 1; j <= n; j++) {
89
+ if (oldLines[i - 1] === newLines[j - 1]) {
90
+ lcs[i][j] = lcs[i - 1][j - 1] + 1;
91
+ }
92
+ else {
93
+ lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
94
+ }
95
+ }
96
+ }
97
+ // Backtrack to find edit script
98
+ const ops = [];
99
+ let i = m;
100
+ let j = n;
101
+ while (i > 0 || j > 0) {
102
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
103
+ ops.unshift({ type: "keep", oldIdx: i - 1, newIdx: j - 1 });
104
+ i--;
105
+ j--;
106
+ }
107
+ else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
108
+ ops.unshift({ type: "insert", newIdx: j - 1 });
109
+ j--;
110
+ }
111
+ else {
112
+ ops.unshift({ type: "delete", oldIdx: i - 1 });
113
+ i--;
114
+ }
115
+ }
116
+ return ops;
117
+ }
118
+ /**
119
+ * Group edit operations into hunks with context lines.
120
+ */
121
+ function groupIntoHunks(ops, oldLines, newLines, contextLines) {
122
+ // Find ranges of changes
123
+ const changeRanges = [];
124
+ let inChange = false;
125
+ let changeStart = 0;
126
+ for (let i = 0; i < ops.length; i++) {
127
+ const op = ops[i];
128
+ if (op.type !== "keep") {
129
+ if (!inChange) {
130
+ inChange = true;
131
+ changeStart = i;
132
+ }
133
+ }
134
+ else if (inChange) {
135
+ changeRanges.push({ start: changeStart, end: i });
136
+ inChange = false;
137
+ }
138
+ }
139
+ if (inChange) {
140
+ changeRanges.push({ start: changeStart, end: ops.length });
141
+ }
142
+ if (changeRanges.length === 0) {
143
+ return [];
144
+ }
145
+ // Merge ranges that are close together (within 2*contextLines)
146
+ const mergedRanges = [];
147
+ let currentRange = { ...changeRanges[0] };
148
+ for (let i = 1; i < changeRanges.length; i++) {
149
+ const range = changeRanges[i];
150
+ if (range.start - currentRange.end <= contextLines * 2) {
151
+ currentRange.end = range.end;
152
+ }
153
+ else {
154
+ mergedRanges.push(currentRange);
155
+ currentRange = { ...range };
156
+ }
157
+ }
158
+ mergedRanges.push(currentRange);
159
+ // Build hunks with context
160
+ const hunks = [];
161
+ for (const range of mergedRanges) {
162
+ const contextStart = Math.max(0, range.start - contextLines);
163
+ const contextEnd = Math.min(ops.length, range.end + contextLines);
164
+ const hunkOps = ops.slice(contextStart, contextEnd);
165
+ const lines = [];
166
+ let oldStart = 1;
167
+ let newStart = 1;
168
+ let oldCount = 0;
169
+ let newCount = 0;
170
+ // Calculate starting positions
171
+ for (let i = 0; i < contextStart; i++) {
172
+ const op = ops[i];
173
+ if (op.type === "keep" || op.type === "delete") {
174
+ oldStart++;
175
+ }
176
+ if (op.type === "keep" || op.type === "insert") {
177
+ newStart++;
178
+ }
179
+ }
180
+ // Build hunk lines
181
+ for (const op of hunkOps) {
182
+ switch (op.type) {
183
+ case "keep":
184
+ lines.push(` ${oldLines[op.oldIdx]}`);
185
+ oldCount++;
186
+ newCount++;
187
+ break;
188
+ case "delete":
189
+ lines.push(`-${oldLines[op.oldIdx]}`);
190
+ oldCount++;
191
+ break;
192
+ case "insert":
193
+ lines.push(`+${newLines[op.newIdx]}`);
194
+ newCount++;
195
+ break;
196
+ }
197
+ }
198
+ hunks.push({ oldStart, oldCount, newStart, newCount, lines });
199
+ }
200
+ return hunks;
201
+ }
202
+ /**
203
+ * Create an empty diff stats object.
204
+ */
205
+ export function createDiffStats() {
206
+ return { newCount: 0, modifiedCount: 0, unchangedCount: 0 };
207
+ }
208
+ /**
209
+ * Increment the appropriate counter in diff stats.
210
+ */
211
+ export function incrementDiffStats(stats, status) {
212
+ switch (status) {
213
+ case "NEW":
214
+ stats.newCount++;
215
+ break;
216
+ case "MODIFIED":
217
+ stats.modifiedCount++;
218
+ break;
219
+ case "UNCHANGED":
220
+ stats.unchangedCount++;
221
+ break;
222
+ }
223
+ }
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { resolve, isAbsolute, normalize, extname } from "node:path";
2
+ import { resolve, isAbsolute, normalize, extname, relative } from "node:path";
3
3
  import JSON5 from "json5";
4
4
  import { parse as parseYaml } from "yaml";
5
5
  /**
@@ -27,9 +27,11 @@ export function resolveFileReference(reference, configDir) {
27
27
  const normalizedResolved = normalize(resolvedPath);
28
28
  const normalizedConfigDir = normalize(configDir);
29
29
  // Security: ensure path stays within config directory tree
30
- // Use path separator to ensure we're checking directory boundaries
31
- if (!normalizedResolved.startsWith(normalizedConfigDir + "/") &&
32
- normalizedResolved !== normalizedConfigDir) {
30
+ // Fix for issue #89: Use path.relative() instead of hardcoded "/" separator
31
+ // The old approach (!path.startsWith(configDir + "/")) fails on Windows
32
+ // where normalize() returns paths with backslash separators.
33
+ const pathFromConfig = relative(normalizedConfigDir, normalizedResolved);
34
+ if (pathFromConfig.startsWith("..") || isAbsolute(pathFromConfig)) {
33
35
  throw new Error(`File reference "${reference}" escapes config directory. ` +
34
36
  `References must be within "${configDir}".`);
35
37
  }
package/dist/git-ops.d.ts CHANGED
@@ -40,12 +40,23 @@ export declare class GitOps {
40
40
  * @param fileName - The file path relative to the work directory
41
41
  */
42
42
  setExecutable(fileName: string): Promise<void>;
43
+ /**
44
+ * Get the content of a file in the workspace.
45
+ * Returns null if the file doesn't exist.
46
+ */
47
+ getFileContent(fileName: string): string | null;
43
48
  /**
44
49
  * Checks if writing the given content would result in changes.
45
50
  * Works in both normal and dry-run modes by comparing content directly.
46
51
  */
47
52
  wouldChange(fileName: string, content: string): boolean;
48
53
  hasChanges(): Promise<boolean>;
54
+ /**
55
+ * Get list of files that have changes according to git status.
56
+ * Returns relative file paths for files that are modified, added, or untracked.
57
+ * Uses the same this.exec() pattern as other methods in this class.
58
+ */
59
+ getChangedFiles(): Promise<string[]>;
49
60
  /**
50
61
  * Check if there are staged changes ready to commit.
51
62
  * Uses `git diff --cached --quiet` which exits with 1 if there are staged changes.
package/dist/git-ops.js CHANGED
@@ -93,6 +93,22 @@ export class GitOps {
93
93
  const relativePath = relative(this.workDir, filePath);
94
94
  await this.exec(`git update-index --add --chmod=+x ${escapeShellArg(relativePath)}`, this.workDir);
95
95
  }
96
+ /**
97
+ * Get the content of a file in the workspace.
98
+ * Returns null if the file doesn't exist.
99
+ */
100
+ getFileContent(fileName) {
101
+ const filePath = this.validatePath(fileName);
102
+ if (!existsSync(filePath)) {
103
+ return null;
104
+ }
105
+ try {
106
+ return readFileSync(filePath, "utf-8");
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
96
112
  /**
97
113
  * Checks if writing the given content would result in changes.
98
114
  * Works in both normal and dry-run modes by comparing content directly.
@@ -118,6 +134,20 @@ export class GitOps {
118
134
  const status = await this.exec("git status --porcelain", this.workDir);
119
135
  return status.length > 0;
120
136
  }
137
+ /**
138
+ * Get list of files that have changes according to git status.
139
+ * Returns relative file paths for files that are modified, added, or untracked.
140
+ * Uses the same this.exec() pattern as other methods in this class.
141
+ */
142
+ async getChangedFiles() {
143
+ const status = await this.exec("git status --porcelain", this.workDir);
144
+ if (!status)
145
+ return [];
146
+ return status
147
+ .split("\n")
148
+ .filter((line) => line.length > 0)
149
+ .map((line) => line.slice(3)); // Remove status prefix (e.g., " M ", "?? ", "A ")
150
+ }
121
151
  /**
122
152
  * Check if there are staged changes ready to commit.
123
153
  * Uses `git diff --cached --quiet` which exits with 1 if there are staged changes.
package/dist/logger.d.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import { FileStatus } from "./diff-utils.js";
1
2
  export interface ILogger {
2
3
  info(message: string): void;
4
+ fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
5
+ diffSummary(newCount: number, modifiedCount: number, unchangedCount: number): void;
3
6
  }
4
7
  export interface LoggerStats {
5
8
  total: number;
@@ -15,6 +18,15 @@ export declare class Logger {
15
18
  success(current: number, repoName: string, message: string): void;
16
19
  skip(current: number, repoName: string, reason: string): void;
17
20
  error(current: number, repoName: string, error: string): void;
21
+ /**
22
+ * Display a file diff with status badge.
23
+ * Used in dry-run mode to show what would change.
24
+ */
25
+ fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
26
+ /**
27
+ * Display summary statistics for dry-run diff.
28
+ */
29
+ diffSummary(newCount: number, modifiedCount: number, unchangedCount: number): void;
18
30
  summary(): void;
19
31
  hasFailures(): boolean;
20
32
  }
package/dist/logger.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import chalk from "chalk";
2
+ import { formatStatusBadge } from "./diff-utils.js";
2
3
  export class Logger {
3
4
  stats = {
4
5
  total: 0,
@@ -31,6 +32,35 @@ export class Logger {
31
32
  console.log(chalk.red(`[${current}/${this.stats.total}] ✗`) +
32
33
  ` ${repoName}: ${error}`);
33
34
  }
35
+ /**
36
+ * Display a file diff with status badge.
37
+ * Used in dry-run mode to show what would change.
38
+ */
39
+ fileDiff(fileName, status, diffLines) {
40
+ const badge = formatStatusBadge(status);
41
+ console.log(` ${badge} ${fileName}`);
42
+ // Only show diff lines for NEW or MODIFIED files
43
+ if (status !== "UNCHANGED" && diffLines.length > 0) {
44
+ for (const line of diffLines) {
45
+ console.log(` ${line}`);
46
+ }
47
+ }
48
+ }
49
+ /**
50
+ * Display summary statistics for dry-run diff.
51
+ */
52
+ diffSummary(newCount, modifiedCount, unchangedCount) {
53
+ const parts = [];
54
+ if (newCount > 0)
55
+ parts.push(chalk.green(`${newCount} new`));
56
+ if (modifiedCount > 0)
57
+ parts.push(chalk.yellow(`${modifiedCount} modified`));
58
+ if (unchangedCount > 0)
59
+ parts.push(chalk.gray(`${unchangedCount} unchanged`));
60
+ if (parts.length > 0) {
61
+ console.log(chalk.gray(` Summary: ${parts.join(", ")}`));
62
+ }
63
+ }
34
64
  summary() {
35
65
  console.log("");
36
66
  console.log(chalk.bold("Summary:"));
@@ -7,6 +7,7 @@ import { createPR, mergePR } from "./pr-creator.js";
7
7
  import { logger } from "./logger.js";
8
8
  import { getPRStrategy } from "./strategies/index.js";
9
9
  import { defaultExecutor } from "./command-executor.js";
10
+ import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
10
11
  /**
11
12
  * Determines if a file should be marked as executable.
12
13
  * .sh files are auto-executable unless explicit executable: false is set.
@@ -69,7 +70,19 @@ export class RepositoryProcessor {
69
70
  this.log.info(`Creating branch: ${branchName}`);
70
71
  await this.gitOps.createBranch(branchName);
71
72
  // Step 5: Write all config files and track changes
73
+ //
74
+ // DESIGN NOTE: Change detection differs between dry-run and normal mode:
75
+ // - Dry-run: Uses wouldChange() for read-only content comparison (no side effects)
76
+ // - Normal: Uses git status after writing (source of truth for what git will commit)
77
+ //
78
+ // This is intentional. git status is more accurate because it respects .gitattributes
79
+ // (line ending normalization, filters) and detects executable bit changes. However,
80
+ // it requires actually writing files, which defeats dry-run's purpose.
81
+ //
82
+ // For config files (JSON/YAML), these approaches produce identical results in practice.
83
+ // Edge cases (repos with unusual git attributes on config files) are essentially nonexistent.
72
84
  const changedFiles = [];
85
+ const diffStats = createDiffStats();
73
86
  for (const file of repoConfig.files) {
74
87
  const filePath = join(workDir, file.fileName);
75
88
  const fileExistsLocal = existsSync(filePath);
@@ -93,10 +106,18 @@ export class RepositoryProcessor {
93
106
  ? "update"
94
107
  : "create";
95
108
  if (dryRun) {
96
- // In dry-run, check if file would change without writing
97
- if (this.gitOps.wouldChange(file.fileName, fileContent)) {
109
+ // In dry-run, check if file would change and show diff
110
+ const existingContent = this.gitOps.getFileContent(file.fileName);
111
+ const changed = this.gitOps.wouldChange(file.fileName, fileContent);
112
+ const status = getFileStatus(existingContent !== null, changed);
113
+ // Track stats
114
+ incrementDiffStats(diffStats, status);
115
+ if (changed) {
98
116
  changedFiles.push({ fileName: file.fileName, action });
99
117
  }
118
+ // Generate and display diff
119
+ const diffLines = generateDiff(existingContent, fileContent, file.fileName);
120
+ this.log.fileDiff(file.fileName, status, diffLines);
100
121
  }
101
122
  else {
102
123
  // Write the file
@@ -115,6 +136,10 @@ export class RepositoryProcessor {
115
136
  await this.gitOps.setExecutable(file.fileName);
116
137
  }
117
138
  }
139
+ // Show diff summary in dry-run mode
140
+ if (dryRun) {
141
+ this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount);
142
+ }
118
143
  // Step 6: Check for changes (exclude skipped files)
119
144
  let hasChanges;
120
145
  if (dryRun) {
@@ -124,15 +149,21 @@ export class RepositoryProcessor {
124
149
  hasChanges = await this.gitOps.hasChanges();
125
150
  // If there are changes, determine which files changed
126
151
  if (hasChanges) {
127
- // Rebuild the changed files list by checking git status
128
- // Skip files that were already marked as skipped (createOnly)
152
+ // Get the actual list of changed files from git status
153
+ const gitChangedFiles = new Set(await this.gitOps.getChangedFiles());
154
+ // Preserve skipped files (createOnly)
129
155
  const skippedFiles = new Set(changedFiles
130
156
  .filter((f) => f.action === "skip")
131
157
  .map((f) => f.fileName));
158
+ // Only add files that actually changed according to git
132
159
  for (const file of repoConfig.files) {
133
160
  if (skippedFiles.has(file.fileName)) {
134
161
  continue; // Already tracked as skipped
135
162
  }
163
+ // Only include files that git reports as changed
164
+ if (!gitChangedFiles.has(file.fileName)) {
165
+ continue; // File didn't actually change
166
+ }
136
167
  const filePath = join(workDir, file.fileName);
137
168
  const action = existsSync(filePath)
138
169
  ? "update"
@@ -6,6 +6,10 @@
6
6
  * @returns The escaped string wrapped in single quotes
7
7
  */
8
8
  export function escapeShellArg(arg) {
9
+ // Defense-in-depth: reject null bytes even if upstream validation should catch them
10
+ if (arg.includes("\0")) {
11
+ throw new Error("Shell argument contains null byte");
12
+ }
9
13
  // Use single quotes and escape any single quotes within
10
14
  // 'string' -> quote ends, escaped quote, quote starts again
11
15
  return `'${arg.replace(/'/g, "'\\''")}'`;
@@ -52,8 +52,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
52
52
  // Extract PR number from URL
53
53
  const prNumber = existingUrl.match(/\/pull\/(\d+)/)?.[1];
54
54
  if (!prNumber) {
55
- logger.info(`Warning: Could not extract PR number from URL: ${existingUrl}`);
56
- return false;
55
+ throw new Error(`Could not extract PR number from URL: ${existingUrl}`);
57
56
  }
58
57
  // Close the PR and delete the branch
59
58
  const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --delete-branch`;
@@ -78,10 +77,13 @@ export class GitHubPRStrategy extends BasePRStrategy {
78
77
  const command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
79
78
  try {
80
79
  const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
81
- // Extract URL from output
82
- const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
80
+ // Extract URL from output - use strict regex for valid PR URLs only
81
+ const urlMatch = result.match(/https:\/\/github\.com\/[\w-]+\/[\w.-]+\/pull\/\d+/);
82
+ if (!urlMatch) {
83
+ throw new Error(`Could not parse PR URL from output: ${result}`);
84
+ }
83
85
  return {
84
- url: urlMatch?.[0] ?? result,
86
+ url: urlMatch[0],
85
87
  success: true,
86
88
  message: "PR created successfully",
87
89
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
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",
@@ -26,7 +26,7 @@
26
26
  "build": "tsc",
27
27
  "start": "node dist/index.js",
28
28
  "dev": "ts-node src/index.ts",
29
- "test": "node --import tsx --test src/config.test.ts src/merge.test.ts src/env.test.ts src/repo-detector.test.ts src/pr-creator.test.ts src/git-ops.test.ts src/logger.test.ts src/workspace-utils.test.ts src/strategies/pr-strategy.test.ts src/strategies/github-pr-strategy.test.ts src/strategies/azure-pr-strategy.test.ts src/repository-processor.test.ts src/retry-utils.test.ts src/command-executor.test.ts src/shell-utils.test.ts src/index.test.ts src/config-formatter.test.ts src/config-validator.test.ts src/config-normalizer.test.ts",
29
+ "test": "node --import tsx --test src/config.test.ts src/merge.test.ts src/env.test.ts src/repo-detector.test.ts src/pr-creator.test.ts src/git-ops.test.ts src/logger.test.ts src/workspace-utils.test.ts src/strategies/pr-strategy.test.ts src/strategies/github-pr-strategy.test.ts src/strategies/azure-pr-strategy.test.ts src/repository-processor.test.ts src/retry-utils.test.ts src/command-executor.test.ts src/shell-utils.test.ts src/index.test.ts src/config-formatter.test.ts src/config-validator.test.ts src/config-normalizer.test.ts src/diff-utils.test.ts",
30
30
  "test:integration": "npm run build && node --import tsx --test src/integration.test.ts",
31
31
  "prepublishOnly": "npm run build"
32
32
  },