@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 +97 -53
- 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 +11 -0
- package/dist/git-ops.js +30 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +30 -0
- package/dist/repo-detector.d.ts +9 -2
- package/dist/repo-detector.js +84 -2
- package/dist/repository-processor.js +35 -4
- package/dist/shell-utils.js +4 -0
- package/dist/strategies/azure-pr-strategy.js +1 -1
- package/dist/strategies/github-pr-strategy.js +7 -5
- package/dist/strategies/gitlab-pr-strategy.d.ts +27 -0
- package/dist/strategies/gitlab-pr-strategy.js +276 -0
- package/dist/strategies/index.d.ts +1 -0
- package/dist/strategies/index.js +6 -1
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@aspruyt/xfg)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub
|
|
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
|
|
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
|
|
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
|
|
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` | `
|
|
201
|
-
| `mergeStrategy` | How to merge: `merge`, `squash`, `rebase` | `
|
|
202
|
-
| `deleteBranch` | Delete source branch after merge | `
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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)
|
|
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] -->
|
|
731
|
-
|
|
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
|
|
735
|
-
COMMIT --> PUSH[Push to Remote]
|
|
758
|
+
CHECK -->|Yes| COMMIT[Commit & Push]
|
|
736
759
|
end
|
|
737
760
|
|
|
738
|
-
subgraph Platform["
|
|
739
|
-
|
|
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
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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.
|
|
761
|
-
7.
|
|
762
|
-
8.
|
|
763
|
-
9.
|
|
764
|
-
10.
|
|
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
|
|
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
|
-
#
|
|
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 {
|
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
|
@@ -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
|
}
|