@aspruyt/xfg 1.0.3 → 1.2.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
@@ -5,7 +5,7 @@
5
5
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/xfg.svg)](https://www.npmjs.com/package/@aspruyt/xfg)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
 
8
- A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub and Azure DevOps repositories by creating pull requests. Output format is automatically detected from the target filename extension (`.json` → JSON, `.json5` → JSON5, `.yaml`/`.yml` → YAML, other → text).
8
+ A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub, Azure DevOps, and GitLab repositories by creating pull requests (or merge requests for GitLab). Output format is automatically detected from the target filename extension (`.json` → JSON, `.json5` → JSON5, `.yaml`/`.yml` → YAML, other → text).
9
9
 
10
10
  ## Table of Contents
11
11
 
@@ -72,7 +72,7 @@ xfg --config ./config.yaml
72
72
  - **Override Mode** - Skip merging entirely for specific repos
73
73
  - **Empty Files** - Create files with no content (e.g., `.prettierignore`)
74
74
  - **YAML Comments** - Add header comments and schema directives to YAML files
75
- - **GitHub & Azure DevOps** - Works with both platforms
75
+ - **Multi-Platform** - Works with GitHub, Azure DevOps, and GitLab (including self-hosted)
76
76
  - **Auto-Merge PRs** - Automatically merge PRs when checks pass, or force merge with admin privileges
77
77
  - **Dry-Run Mode** - Preview changes without creating PRs
78
78
  - **Error Resilience** - Continues processing if individual repos fail
@@ -118,6 +118,20 @@ az login
118
118
  az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG project=YOUR_PROJECT
119
119
  ```
120
120
 
121
+ ### GitLab Authentication
122
+
123
+ Before using with GitLab repositories, authenticate with the GitLab CLI:
124
+
125
+ ```bash
126
+ glab auth login
127
+ ```
128
+
129
+ For self-hosted GitLab instances:
130
+
131
+ ```bash
132
+ glab auth login --hostname gitlab.example.com
133
+ ```
134
+
121
135
  ## Usage
122
136
 
123
137
  ```bash
@@ -136,16 +150,16 @@ xfg --config ./config.yaml --branch feature/update-eslint
136
150
 
137
151
  ### Options
138
152
 
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 |
153
+ | Option | Alias | Description | Required |
154
+ | ------------------ | ----- | ------------------------------------------------------------------------------ | -------- |
155
+ | `--config` | `-c` | Path to YAML config file | Yes |
156
+ | `--dry-run` | `-d` | Show what would be done without making changes | No |
157
+ | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
158
+ | `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
159
+ | `--branch` | `-b` | Override branch name (default: `chore/sync-{filename}` or `chore/sync-config`) | No |
160
+ | `--merge` | `-m` | PR merge mode: `manual`, `auto` (default), `force` (bypass checks) | No |
161
+ | `--merge-strategy` | | Merge strategy: `merge`, `squash` (default), `rebase` | No |
162
+ | `--delete-branch` | | Delete source branch after merge | No |
149
163
 
150
164
  ## Configuration Format
151
165
 
@@ -197,9 +211,9 @@ repos: # List of repositories
197
211
 
198
212
  | Field | Description | Default |
199
213
  | --------------- | --------------------------------------------------------------------------- | -------- |
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` |
214
+ | `merge` | Merge mode: `manual` (leave open), `auto` (merge when checks pass), `force` | `auto` |
215
+ | `mergeStrategy` | How to merge: `merge`, `squash`, `rebase` | `squash` |
216
+ | `deleteBranch` | Delete source branch after merge | `true` |
203
217
  | `bypassReason` | Reason for bypassing policies (Azure DevOps only, required for `force`) | - |
204
218
 
205
219
  ### Per-Repo File Override Fields
@@ -649,7 +663,7 @@ config/
649
663
 
650
664
  ### Auto-Merge PRs
651
665
 
652
- Configure PRs to merge automatically when checks pass, or force merge using admin privileges:
666
+ By default, xfg enables auto-merge on PRs when checks pass. You can override this behavior per-repo or via CLI flags:
653
667
 
654
668
  ```yaml
655
669
  files:
@@ -658,18 +672,18 @@ files:
658
672
  semi: false
659
673
  singleQuote: true
660
674
 
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
-
675
+ # Default behavior (auto-merge with squash, delete branch) - no prOptions needed
667
676
  repos:
668
- # These repos use global prOptions (auto-merge)
677
+ # These repos use defaults (auto-merge with squash, delete branch)
669
678
  - git:
670
679
  - git@github.com:org/frontend.git
671
680
  - git@github.com:org/backend.git
672
681
 
682
+ # This repo overrides to manual (leave PR open for review)
683
+ - git: git@github.com:org/needs-review.git
684
+ prOptions:
685
+ merge: manual
686
+
673
687
  # This repo overrides to force merge (bypass required reviews)
674
688
  - git: git@github.com:org/internal-tool.git
675
689
  prOptions:
@@ -679,11 +693,11 @@ repos:
679
693
 
680
694
  **Merge Modes:**
681
695
 
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` |
696
+ | Mode | GitHub Behavior | Azure DevOps Behavior | GitLab Behavior |
697
+ | -------- | ---------------------------------------------------- | -------------------------------------- | ------------------------------------------ |
698
+ | `manual` | Leave PR open for review | Leave PR open for review | Leave MR open for review |
699
+ | `auto` | Enable auto-merge (requires repo setup, **default**) | Enable auto-complete (**default**) | Merge when pipeline succeeds (**default**) |
700
+ | `force` | Merge with `--admin` (bypass checks) | Bypass policies with `--bypass-policy` | Merge immediately |
687
701
 
688
702
  **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
703
 
@@ -694,11 +708,11 @@ gh repo edit org/repo --enable-auto-merge
694
708
  **CLI Override:** You can override config file settings with CLI flags:
695
709
 
696
710
  ```bash
711
+ # Disable auto-merge, leave PRs open for review
712
+ xfg --config ./config.yaml --merge manual
713
+
697
714
  # Force merge all PRs (useful for urgent updates)
698
715
  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
716
  ```
703
717
 
704
718
  ## Supported Git URL Formats
@@ -713,6 +727,14 @@ xfg --config ./config.yaml --merge auto --merge-strategy squash --delete-branch
713
727
  - SSH: `git@ssh.dev.azure.com:v3/organization/project/repo`
714
728
  - HTTPS: `https://dev.azure.com/organization/project/_git/repo`
715
729
 
730
+ ### GitLab
731
+
732
+ - SSH: `git@gitlab.com:owner/repo.git`
733
+ - HTTPS: `https://gitlab.com/owner/repo.git`
734
+ - Nested groups: `git@gitlab.com:org/group/subgroup/repo.git`
735
+ - Self-hosted SSH: `git@gitlab.example.com:owner/repo.git`
736
+ - Self-hosted HTTPS: `https://gitlab.example.com/owner/repo.git`
737
+
716
738
  ## How It Works
717
739
 
718
740
  ```mermaid
@@ -727,23 +749,30 @@ flowchart TB
727
749
  end
728
750
 
729
751
  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]
752
+ CLONE[Clone Repo] --> DETECT_BRANCH[Detect Default Branch]
753
+ DETECT_BRANCH --> CLOSE_PR[Close Existing PR<br/>if exists]
754
+ CLOSE_PR --> BRANCH[Create Fresh Branch]
755
+ BRANCH --> WRITE[Write Config Files]
732
756
  WRITE --> CHECK{Changes?}
733
757
  CHECK -->|No| SKIP[Skip - No Changes]
734
- CHECK -->|Yes| COMMIT[Commit Changes]
735
- COMMIT --> PUSH[Push to Remote]
758
+ CHECK -->|Yes| COMMIT[Commit & Push]
736
759
  end
737
760
 
738
- subgraph Platform["Platform Detection"]
739
- PUSH --> DETECT{GitHub or<br/>Azure DevOps?}
761
+ subgraph Platform["PR Creation"]
762
+ COMMIT --> PR_DETECT{Platform?}
763
+ PR_DETECT -->|GitHub| GH_PR[Create PR via gh CLI]
764
+ PR_DETECT -->|Azure DevOps| AZ_PR[Create PR via az CLI]
765
+ PR_DETECT -->|GitLab| GL_PR[Create MR via glab CLI]
766
+ GH_PR --> PR_CREATED[PR/MR Created]
767
+ AZ_PR --> PR_CREATED
768
+ GL_PR --> PR_CREATED
740
769
  end
741
770
 
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"/]
771
+ subgraph AutoMerge["Auto-Merge (default)"]
772
+ PR_CREATED --> MERGE_MODE{Merge Mode?}
773
+ MERGE_MODE -->|manual| OPEN[Leave PR Open]
774
+ MERGE_MODE -->|auto| AUTO[Enable Auto-Merge]
775
+ MERGE_MODE -->|force| FORCE[Bypass & Merge]
747
776
  end
748
777
 
749
778
  YAML --> EXPAND
@@ -757,11 +786,14 @@ For each repository in the config, the tool:
757
786
  3. Interpolates environment variables
758
787
  4. Cleans the temporary workspace
759
788
  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
789
+ 6. Detects the default branch (main/master)
790
+ 7. Closes any existing PR on the branch and deletes the remote branch (fresh start)
791
+ 8. Creates a fresh branch from the default branch
792
+ 9. Writes all config files (JSON, JSON5, YAML, or text based on filename extension)
793
+ 10. Checks for changes (skips if no changes)
794
+ 11. Commits and pushes changes
795
+ 12. Creates a pull request
796
+ 13. Handles auto-merge based on configuration (auto by default)
765
797
 
766
798
  ## CI/CD Integration
767
799
 
@@ -882,18 +914,34 @@ az login
882
914
  az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG
883
915
  ```
884
916
 
917
+ **GitLab:**
918
+
919
+ ```bash
920
+ # Check authentication status
921
+ glab auth status
922
+
923
+ # Re-authenticate if needed
924
+ glab auth login
925
+
926
+ # For self-hosted instances
927
+ glab auth login --hostname gitlab.example.com
928
+ ```
929
+
885
930
  ### Permission Denied
886
931
 
887
932
  - Ensure your token has write access to the target repositories
888
933
  - For GitHub, the token needs `repo` scope
889
934
  - For Azure DevOps, ensure the user/service account has "Contribute to pull requests" permission
935
+ - For GitLab, ensure the user has at least "Developer" role on the project
890
936
 
891
937
  ### Branch Already Exists
892
938
 
893
- The tool automatically reuses existing branches. If you see unexpected behavior:
939
+ 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.
940
+
941
+ If you see issues with stale branches:
894
942
 
895
943
  ```bash
896
- # Delete the remote branch to start fresh
944
+ # Manually delete the remote branch if needed
897
945
  git push origin --delete chore/sync-config
898
946
  ```
899
947
 
@@ -985,7 +1033,3 @@ npm test
985
1033
  # Build
986
1034
  npm run build
987
1035
  ```
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
  }