@aspruyt/xfg 1.0.2 → 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 +57 -51
- package/dist/config-normalizer.js +4 -1
- package/dist/config-validator.js +3 -3
- package/dist/diff-utils.d.ts +31 -0
- package/dist/diff-utils.js +223 -0
- package/dist/file-reference-resolver.js +6 -4
- package/dist/git-ops.d.ts +14 -2
- package/dist/git-ops.js +37 -4
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +30 -0
- package/dist/repository-processor.js +35 -4
- package/dist/shell-utils.js +4 -0
- package/dist/strategies/github-pr-strategy.js +7 -5
- package/package.json +2 -2
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
|
|
140
|
-
| ------------------ | ----- |
|
|
141
|
-
| `--config` | `-c` | Path to YAML config file
|
|
142
|
-
| `--dry-run` | `-d` | Show what would be done without making changes
|
|
143
|
-
| `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`)
|
|
144
|
-
| `--retries` | `-r` | Number of retries for network operations (default: 3)
|
|
145
|
-
| `--branch` | `-b` | Override branch name (default: `chore/sync-config`)
|
|
146
|
-
| `--merge` | `-m` | PR merge mode: `manual
|
|
147
|
-
| `--merge-strategy` | | Merge strategy: `merge` (default), `
|
|
148
|
-
| `--delete-branch` | | Delete source branch after merge
|
|
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` | `
|
|
201
|
-
| `mergeStrategy` | How to merge: `merge`, `squash`, `rebase` | `
|
|
202
|
-
| `deleteBranch` | Delete source branch after merge | `
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
683
|
-
| -------- |
|
|
684
|
-
| `manual` | Leave PR open for review
|
|
685
|
-
| `auto` | Enable auto-merge (requires repo setup) | Enable auto-complete
|
|
686
|
-
| `force` | Merge with `--admin` (bypass checks)
|
|
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] -->
|
|
731
|
-
|
|
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
|
|
735
|
-
COMMIT --> PUSH[Push to Remote]
|
|
736
|
+
CHECK -->|Yes| COMMIT[Commit & Push]
|
|
736
737
|
end
|
|
737
738
|
|
|
738
|
-
subgraph Platform["
|
|
739
|
-
|
|
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
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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.
|
|
761
|
-
7.
|
|
762
|
-
8.
|
|
763
|
-
9.
|
|
764
|
-
10.
|
|
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
|
|
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
|
-
#
|
|
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 {
|
package/dist/config-validator.js
CHANGED
|
@@ -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()
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
@@ -34,17 +34,29 @@ export declare class GitOps {
|
|
|
34
34
|
createBranch(branchName: string): Promise<void>;
|
|
35
35
|
writeFile(fileName: string, content: string): void;
|
|
36
36
|
/**
|
|
37
|
-
* Marks a file as executable in git
|
|
38
|
-
*
|
|
37
|
+
* Marks a file as executable both on the filesystem and in git's index.
|
|
38
|
+
* - Filesystem: Uses chmod to set 755 permissions (rwxr-xr-x)
|
|
39
|
+
* - Git index: Uses update-index --chmod=+x so the mode is committed
|
|
39
40
|
* @param fileName - The file path relative to the work directory
|
|
40
41
|
*/
|
|
41
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;
|
|
42
48
|
/**
|
|
43
49
|
* Checks if writing the given content would result in changes.
|
|
44
50
|
* Works in both normal and dry-run modes by comparing content directly.
|
|
45
51
|
*/
|
|
46
52
|
wouldChange(fileName: string, content: string): boolean;
|
|
47
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[]>;
|
|
48
60
|
/**
|
|
49
61
|
* Check if there are staged changes ready to commit.
|
|
50
62
|
* Uses `git diff --cached --quiet` which exits with 1 if there are staged changes.
|
package/dist/git-ops.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync, } from "node:fs";
|
|
1
|
+
import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, } from "node:fs";
|
|
2
2
|
import { join, resolve, relative, isAbsolute, dirname } from "node:path";
|
|
3
3
|
import { escapeShellArg } from "./shell-utils.js";
|
|
4
4
|
import { defaultExecutor } from "./command-executor.js";
|
|
@@ -77,8 +77,9 @@ export class GitOps {
|
|
|
77
77
|
writeFileSync(filePath, normalized, "utf-8");
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
|
-
* Marks a file as executable in git
|
|
81
|
-
*
|
|
80
|
+
* Marks a file as executable both on the filesystem and in git's index.
|
|
81
|
+
* - Filesystem: Uses chmod to set 755 permissions (rwxr-xr-x)
|
|
82
|
+
* - Git index: Uses update-index --chmod=+x so the mode is committed
|
|
82
83
|
* @param fileName - The file path relative to the work directory
|
|
83
84
|
*/
|
|
84
85
|
async setExecutable(fileName) {
|
|
@@ -86,10 +87,28 @@ export class GitOps {
|
|
|
86
87
|
return;
|
|
87
88
|
}
|
|
88
89
|
const filePath = this.validatePath(fileName);
|
|
89
|
-
//
|
|
90
|
+
// Set filesystem permissions (755 = rwxr-xr-x)
|
|
91
|
+
chmodSync(filePath, 0o755);
|
|
92
|
+
// Also update git's index so the executable bit is committed
|
|
90
93
|
const relativePath = relative(this.workDir, filePath);
|
|
91
94
|
await this.exec(`git update-index --add --chmod=+x ${escapeShellArg(relativePath)}`, this.workDir);
|
|
92
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
|
+
}
|
|
93
112
|
/**
|
|
94
113
|
* Checks if writing the given content would result in changes.
|
|
95
114
|
* Works in both normal and dry-run modes by comparing content directly.
|
|
@@ -115,6 +134,20 @@ export class GitOps {
|
|
|
115
134
|
const status = await this.exec("git status --porcelain", this.workDir);
|
|
116
135
|
return status.length > 0;
|
|
117
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
|
+
}
|
|
118
151
|
/**
|
|
119
152
|
* Check if there are staged changes ready to commit.
|
|
120
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
|
|
97
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
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"
|
package/dist/shell-utils.js
CHANGED
|
@@ -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
|
-
|
|
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\/[
|
|
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
|
|
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
|
+
"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
|
},
|