@aspruyt/xfg 3.7.6 → 3.7.7
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/dist/cli/index.d.ts +6 -0
- package/dist/cli/index.js +9 -0
- package/dist/cli/program.d.ts +2 -0
- package/dist/cli/program.js +70 -0
- package/dist/cli/settings-command.d.ts +10 -0
- package/dist/cli/settings-command.js +228 -0
- package/dist/cli/sync-command.d.ts +25 -0
- package/dist/cli/sync-command.js +155 -0
- package/dist/cli/types.d.ts +45 -0
- package/dist/cli/types.js +15 -0
- package/dist/cli.js +2 -19
- package/dist/{file-reference-resolver.d.ts → config/file-reference-resolver.d.ts} +1 -1
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +12 -0
- package/dist/config/loader.d.ts +9 -0
- package/dist/{config.js → config/loader.js} +3 -24
- package/dist/{config-normalizer.d.ts → config/normalizer.d.ts} +1 -1
- package/dist/{config-normalizer.js → config/normalizer.js} +1 -1
- package/dist/{config.d.ts → config/types.d.ts} +5 -9
- package/dist/config/types.js +16 -0
- package/dist/{config-validator.d.ts → config/validator.d.ts} +5 -5
- package/dist/{config-validator.js → config/validator.js} +60 -372
- package/dist/config/validators/file-validator.d.ts +22 -0
- package/dist/config/validators/file-validator.js +46 -0
- package/dist/config/validators/index.d.ts +3 -0
- package/dist/config/validators/index.js +6 -0
- package/dist/config/validators/repo-settings-validator.d.ts +10 -0
- package/dist/config/validators/repo-settings-validator.js +71 -0
- package/dist/config/validators/ruleset-validator.d.ts +18 -0
- package/dist/config/validators/ruleset-validator.js +201 -0
- package/dist/index.d.ts +3 -66
- package/dist/index.js +3 -474
- package/dist/output/index.d.ts +4 -0
- package/dist/output/index.js +8 -0
- package/dist/{summary-utils.d.ts → output/summary-utils.d.ts} +3 -3
- package/dist/settings/index.d.ts +3 -0
- package/dist/settings/index.js +6 -0
- package/dist/{repo-settings-diff.d.ts → settings/repo-settings/diff.d.ts} +2 -2
- package/dist/{repo-settings-plan-formatter.d.ts → settings/repo-settings/formatter.d.ts} +1 -1
- package/dist/{strategies → settings/repo-settings}/github-repo-settings-strategy.d.ts +4 -4
- package/dist/{strategies → settings/repo-settings}/github-repo-settings-strategy.js +3 -3
- package/dist/settings/repo-settings/index.d.ts +5 -0
- package/dist/settings/repo-settings/index.js +10 -0
- package/dist/{repo-settings-processor.d.ts → settings/repo-settings/processor.d.ts} +4 -4
- package/dist/{repo-settings-processor.js → settings/repo-settings/processor.js} +6 -6
- package/dist/{strategies/repo-settings-strategy.d.ts → settings/repo-settings/types.d.ts} +2 -2
- package/dist/{resource-converters.d.ts → settings/resource-converters.d.ts} +4 -4
- package/dist/settings/rulesets/diff-algorithm.d.ts +18 -0
- package/dist/settings/rulesets/diff-algorithm.js +166 -0
- package/dist/{ruleset-diff.d.ts → settings/rulesets/diff.d.ts} +2 -2
- package/dist/{ruleset-diff.js → settings/rulesets/diff.js} +1 -1
- package/dist/{ruleset-plan-formatter.d.ts → settings/rulesets/formatter.d.ts} +4 -12
- package/dist/{ruleset-plan-formatter.js → settings/rulesets/formatter.js} +3 -164
- package/dist/{strategies → settings/rulesets}/github-ruleset-strategy.d.ts +4 -4
- package/dist/{strategies → settings/rulesets}/github-ruleset-strategy.js +3 -3
- package/dist/settings/rulesets/index.d.ts +6 -0
- package/dist/settings/rulesets/index.js +10 -0
- package/dist/{ruleset-processor.d.ts → settings/rulesets/processor.d.ts} +4 -4
- package/dist/{ruleset-processor.js → settings/rulesets/processor.js} +6 -6
- package/dist/{strategies/ruleset-strategy.d.ts → settings/rulesets/types.d.ts} +2 -2
- package/dist/{command-executor.d.ts → shared/command-executor.d.ts} +10 -2
- package/dist/{command-executor.js → shared/command-executor.js} +2 -1
- package/dist/shared/index.d.ts +8 -0
- package/dist/shared/index.js +16 -0
- package/dist/{logger.d.ts → shared/logger.d.ts} +1 -1
- package/dist/{logger.js → shared/logger.js} +1 -1
- package/dist/sync/auth-options-builder.d.ts +12 -0
- package/dist/sync/auth-options-builder.js +54 -0
- package/dist/sync/branch-manager.d.ts +7 -0
- package/dist/sync/branch-manager.js +36 -0
- package/dist/sync/commit-message.d.ts +11 -0
- package/dist/sync/commit-message.js +27 -0
- package/dist/sync/commit-push-manager.d.ts +8 -0
- package/dist/sync/commit-push-manager.js +71 -0
- package/dist/sync/file-sync-orchestrator.d.ts +11 -0
- package/dist/sync/file-sync-orchestrator.js +58 -0
- package/dist/sync/file-writer.d.ts +18 -0
- package/dist/sync/file-writer.js +101 -0
- package/dist/sync/index.d.ts +14 -0
- package/dist/sync/index.js +17 -0
- package/dist/sync/manifest-manager.d.ts +10 -0
- package/dist/sync/manifest-manager.js +64 -0
- package/dist/sync/pr-merge-handler.d.ts +11 -0
- package/dist/sync/pr-merge-handler.js +62 -0
- package/dist/sync/repository-processor.d.ts +30 -0
- package/dist/sync/repository-processor.js +278 -0
- package/dist/sync/repository-session.d.ts +9 -0
- package/dist/sync/repository-session.js +35 -0
- package/dist/sync/types.d.ts +296 -0
- package/dist/{xfg-template.d.ts → sync/xfg-template.d.ts} +2 -2
- package/dist/{authenticated-git-ops.js → vcs/authenticated-git-ops.js} +3 -3
- package/dist/{strategies → vcs}/azure-pr-strategy.d.ts +2 -2
- package/dist/{strategies → vcs}/azure-pr-strategy.js +5 -5
- package/dist/{strategies → vcs}/commit-strategy-selector.d.ts +3 -3
- package/dist/{strategies → vcs}/commit-strategy-selector.js +1 -1
- package/dist/{strategies → vcs}/git-commit-strategy.d.ts +2 -2
- package/dist/{strategies → vcs}/git-commit-strategy.js +3 -3
- package/dist/{git-ops.d.ts → vcs/git-ops.d.ts} +1 -1
- package/dist/{git-ops.js → vcs/git-ops.js} +4 -4
- package/dist/{github-app-token-manager.d.ts → vcs/github-app-token-manager.d.ts} +1 -1
- package/dist/{github-app-token-manager.js → vcs/github-app-token-manager.js} +1 -1
- package/dist/{strategies → vcs}/github-pr-strategy.d.ts +2 -2
- package/dist/{strategies → vcs}/github-pr-strategy.js +30 -33
- package/dist/{strategies → vcs}/gitlab-pr-strategy.d.ts +2 -2
- package/dist/{strategies → vcs}/gitlab-pr-strategy.js +5 -5
- package/dist/{strategies → vcs}/graphql-commit-strategy.d.ts +2 -2
- package/dist/{strategies → vcs}/graphql-commit-strategy.js +3 -3
- package/dist/vcs/index.d.ts +16 -0
- package/dist/{strategies → vcs}/index.js +15 -10
- package/dist/{pr-creator.d.ts → vcs/pr-creator.d.ts} +4 -4
- package/dist/{pr-creator.js → vcs/pr-creator.js} +3 -3
- package/dist/vcs/pr-strategy.d.ts +41 -0
- package/dist/{strategies → vcs}/pr-strategy.js +1 -1
- package/dist/{strategies/pr-strategy.d.ts → vcs/types.d.ts} +32 -35
- package/dist/vcs/types.js +1 -0
- package/package.json +2 -2
- package/dist/repository-processor.d.ts +0 -79
- package/dist/repository-processor.js +0 -659
- package/dist/strategies/commit-strategy.d.ts +0 -36
- package/dist/strategies/index.d.ts +0 -18
- /package/dist/{file-reference-resolver.js → config/file-reference-resolver.js} +0 -0
- /package/dist/{config-formatter.d.ts → config/formatter.d.ts} +0 -0
- /package/dist/{config-formatter.js → config/formatter.js} +0 -0
- /package/dist/{merge.d.ts → config/merge.d.ts} +0 -0
- /package/dist/{merge.js → config/merge.js} +0 -0
- /package/dist/{github-summary.d.ts → output/github-summary.d.ts} +0 -0
- /package/dist/{github-summary.js → output/github-summary.js} +0 -0
- /package/dist/{plan-formatter.d.ts → output/plan-formatter.d.ts} +0 -0
- /package/dist/{plan-formatter.js → output/plan-formatter.js} +0 -0
- /package/dist/{plan-summary.d.ts → output/plan-summary.d.ts} +0 -0
- /package/dist/{plan-summary.js → output/plan-summary.js} +0 -0
- /package/dist/{summary-utils.js → output/summary-utils.js} +0 -0
- /package/dist/{repo-settings-diff.js → settings/repo-settings/diff.js} +0 -0
- /package/dist/{repo-settings-plan-formatter.js → settings/repo-settings/formatter.js} +0 -0
- /package/dist/{strategies/repo-settings-strategy.js → settings/repo-settings/types.js} +0 -0
- /package/dist/{resource-converters.js → settings/resource-converters.js} +0 -0
- /package/dist/{strategies/commit-strategy.js → settings/rulesets/types.js} +0 -0
- /package/dist/{env.d.ts → shared/env.d.ts} +0 -0
- /package/dist/{env.js → shared/env.js} +0 -0
- /package/dist/{repo-detector.d.ts → shared/repo-detector.d.ts} +0 -0
- /package/dist/{repo-detector.js → shared/repo-detector.js} +0 -0
- /package/dist/{retry-utils.d.ts → shared/retry-utils.d.ts} +0 -0
- /package/dist/{retry-utils.js → shared/retry-utils.js} +0 -0
- /package/dist/{sanitize-utils.d.ts → shared/sanitize-utils.d.ts} +0 -0
- /package/dist/{sanitize-utils.js → shared/sanitize-utils.js} +0 -0
- /package/dist/{shell-utils.d.ts → shared/shell-utils.d.ts} +0 -0
- /package/dist/{shell-utils.js → shared/shell-utils.js} +0 -0
- /package/dist/{workspace-utils.d.ts → shared/workspace-utils.d.ts} +0 -0
- /package/dist/{workspace-utils.js → shared/workspace-utils.js} +0 -0
- /package/dist/{diff-utils.d.ts → sync/diff-utils.d.ts} +0 -0
- /package/dist/{diff-utils.js → sync/diff-utils.js} +0 -0
- /package/dist/{manifest.d.ts → sync/manifest.d.ts} +0 -0
- /package/dist/{manifest.js → sync/manifest.js} +0 -0
- /package/dist/{strategies/ruleset-strategy.js → sync/types.js} +0 -0
- /package/dist/{xfg-template.js → sync/xfg-template.js} +0 -0
- /package/dist/{authenticated-git-ops.d.ts → vcs/authenticated-git-ops.d.ts} +0 -0
|
@@ -1,659 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { convertContentToString, } from "./config.js";
|
|
4
|
-
import { getRepoDisplayName, isGitHubRepo, } from "./repo-detector.js";
|
|
5
|
-
import { interpolateXfgContent } from "./xfg-template.js";
|
|
6
|
-
import { GitOps } from "./git-ops.js";
|
|
7
|
-
import { AuthenticatedGitOps, } from "./authenticated-git-ops.js";
|
|
8
|
-
import { createPR, mergePR } from "./pr-creator.js";
|
|
9
|
-
import { logger } from "./logger.js";
|
|
10
|
-
import { getPRStrategy, getCommitStrategy, hasGitHubAppCredentials, } from "./strategies/index.js";
|
|
11
|
-
import { defaultExecutor } from "./command-executor.js";
|
|
12
|
-
import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
|
|
13
|
-
import { loadManifest, saveManifest, updateManifest, updateManifestRulesets, MANIFEST_FILENAME, } from "./manifest.js";
|
|
14
|
-
import { GitHubAppTokenManager } from "./github-app-token-manager.js";
|
|
15
|
-
/**
|
|
16
|
-
* Determines if a file should be marked as executable.
|
|
17
|
-
* .sh files are auto-executable unless explicit executable: false is set.
|
|
18
|
-
* Non-.sh files are executable only if executable: true is explicitly set.
|
|
19
|
-
*/
|
|
20
|
-
function shouldBeExecutable(file) {
|
|
21
|
-
const isShellScript = file.fileName.endsWith(".sh");
|
|
22
|
-
if (file.executable !== undefined) {
|
|
23
|
-
// Explicit setting takes precedence
|
|
24
|
-
return file.executable;
|
|
25
|
-
}
|
|
26
|
-
// Default: .sh files are executable, others are not
|
|
27
|
-
return isShellScript;
|
|
28
|
-
}
|
|
29
|
-
export class RepositoryProcessor {
|
|
30
|
-
gitOps = null;
|
|
31
|
-
gitOpsFactory;
|
|
32
|
-
log;
|
|
33
|
-
retries = 3;
|
|
34
|
-
executor = defaultExecutor;
|
|
35
|
-
tokenManager;
|
|
36
|
-
/**
|
|
37
|
-
* Creates a new RepositoryProcessor.
|
|
38
|
-
* @param gitOpsFactory - Optional factory for creating AuthenticatedGitOps instances (for testing)
|
|
39
|
-
* @param log - Optional logger instance (for testing)
|
|
40
|
-
*/
|
|
41
|
-
constructor(gitOpsFactory, log) {
|
|
42
|
-
this.gitOpsFactory =
|
|
43
|
-
gitOpsFactory ??
|
|
44
|
-
((opts, auth) => new AuthenticatedGitOps(new GitOps(opts), auth));
|
|
45
|
-
this.log = log ?? logger;
|
|
46
|
-
// Initialize GitHub App token manager if credentials are configured
|
|
47
|
-
if (hasGitHubAppCredentials()) {
|
|
48
|
-
this.tokenManager = new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY);
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
this.tokenManager = null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
async process(repoConfig, repoInfo, options) {
|
|
55
|
-
const repoName = getRepoDisplayName(repoInfo);
|
|
56
|
-
const { branchName, workDir, dryRun, prTemplate } = options;
|
|
57
|
-
this.retries = options.retries ?? 3;
|
|
58
|
-
this.executor = options.executor ?? defaultExecutor;
|
|
59
|
-
// Get installation token if needed
|
|
60
|
-
const token = await this.getInstallationToken(repoInfo);
|
|
61
|
-
if (token === null) {
|
|
62
|
-
return {
|
|
63
|
-
success: true,
|
|
64
|
-
repoName,
|
|
65
|
-
message: `No GitHub App installation found for ${repoInfo.owner}`,
|
|
66
|
-
skipped: true,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
// Build auth options - use installation token OR fall back to GH_TOKEN for PAT flow
|
|
70
|
-
const effectiveToken = token ?? (isGitHubRepo(repoInfo) ? process.env.GH_TOKEN : undefined);
|
|
71
|
-
const authOptions = effectiveToken
|
|
72
|
-
? {
|
|
73
|
-
token: effectiveToken,
|
|
74
|
-
host: isGitHubRepo(repoInfo)
|
|
75
|
-
? repoInfo.host
|
|
76
|
-
: "github.com",
|
|
77
|
-
owner: repoInfo.owner,
|
|
78
|
-
repo: repoInfo.repo,
|
|
79
|
-
}
|
|
80
|
-
: undefined;
|
|
81
|
-
this.gitOps = this.gitOpsFactory({
|
|
82
|
-
workDir,
|
|
83
|
-
dryRun,
|
|
84
|
-
retries: this.retries,
|
|
85
|
-
}, authOptions);
|
|
86
|
-
// Determine merge mode early - affects workflow steps
|
|
87
|
-
const mergeMode = repoConfig.prOptions?.merge ?? "auto";
|
|
88
|
-
const isDirectMode = mergeMode === "direct";
|
|
89
|
-
// Warn if mergeStrategy is set with direct mode (irrelevant)
|
|
90
|
-
if (isDirectMode && repoConfig.prOptions?.mergeStrategy) {
|
|
91
|
-
this.log.info(`Warning: mergeStrategy '${repoConfig.prOptions.mergeStrategy}' is ignored in direct mode (no PR created)`);
|
|
92
|
-
}
|
|
93
|
-
try {
|
|
94
|
-
// Step 1: Clean workspace
|
|
95
|
-
this.log.info("Cleaning workspace...");
|
|
96
|
-
this.gitOps.cleanWorkspace();
|
|
97
|
-
// Step 2: Clone repo
|
|
98
|
-
this.log.info("Cloning repository...");
|
|
99
|
-
await this.gitOps.clone(repoInfo.gitUrl);
|
|
100
|
-
// Step 3: Get default branch for PR base
|
|
101
|
-
const { branch: baseBranch, method: detectionMethod } = await this.gitOps.getDefaultBranch();
|
|
102
|
-
this.log.info(`Default branch: ${baseBranch} (detected via ${detectionMethod})`);
|
|
103
|
-
// Step 3.5: Close existing PR if exists (fresh start approach)
|
|
104
|
-
// This ensures isolated sync attempts - each run starts from clean state
|
|
105
|
-
// Skip for direct mode - no PR involved
|
|
106
|
-
if (!dryRun && !isDirectMode) {
|
|
107
|
-
this.log.info("Checking for existing PR...");
|
|
108
|
-
const strategy = getPRStrategy(repoInfo, this.executor);
|
|
109
|
-
const closed = await strategy.closeExistingPR({
|
|
110
|
-
repoInfo,
|
|
111
|
-
branchName,
|
|
112
|
-
baseBranch,
|
|
113
|
-
workDir,
|
|
114
|
-
retries: this.retries,
|
|
115
|
-
token,
|
|
116
|
-
});
|
|
117
|
-
if (closed) {
|
|
118
|
-
this.log.info("Closed existing PR and deleted branch for fresh sync");
|
|
119
|
-
// Prune stale remote tracking refs so --force-with-lease works correctly
|
|
120
|
-
// The remote branch was deleted but local git still has tracking info
|
|
121
|
-
await this.gitOps.fetch({ prune: true });
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
// Step 4: Create branch (always fresh from base branch)
|
|
125
|
-
// Skip for direct mode - stay on default branch
|
|
126
|
-
if (!isDirectMode) {
|
|
127
|
-
this.log.info(`Creating branch: ${branchName}`);
|
|
128
|
-
await this.gitOps.createBranch(branchName);
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
this.log.info(`Direct mode: staying on ${baseBranch}`);
|
|
132
|
-
}
|
|
133
|
-
// Step 5: Write all config files and track changes
|
|
134
|
-
//
|
|
135
|
-
// DESIGN NOTE: Change detection differs between dry-run and normal mode:
|
|
136
|
-
// - Dry-run: Uses wouldChange() for read-only content comparison (no side effects)
|
|
137
|
-
// - Normal: Uses git status after writing (source of truth for what git will commit)
|
|
138
|
-
//
|
|
139
|
-
// Track all file changes with content and action - single source of truth
|
|
140
|
-
// Used for both commit message generation and actual commit
|
|
141
|
-
const fileChangesForCommit = new Map();
|
|
142
|
-
const diffStats = createDiffStats();
|
|
143
|
-
for (const file of repoConfig.files) {
|
|
144
|
-
const filePath = join(workDir, file.fileName);
|
|
145
|
-
const fileExistsLocal = existsSync(filePath);
|
|
146
|
-
// Handle createOnly - check against BASE branch, not current working directory
|
|
147
|
-
// This ensures consistent behavior: createOnly means "only create if doesn't exist on main"
|
|
148
|
-
if (file.createOnly) {
|
|
149
|
-
const existsOnBase = await this.gitOps.fileExistsOnBranch(file.fileName, baseBranch);
|
|
150
|
-
if (existsOnBase) {
|
|
151
|
-
this.log.info(`Skipping ${file.fileName} (createOnly: exists on ${baseBranch})`);
|
|
152
|
-
fileChangesForCommit.set(file.fileName, {
|
|
153
|
-
content: null,
|
|
154
|
-
action: "skip",
|
|
155
|
-
});
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
this.log.info(`Writing ${file.fileName}...`);
|
|
160
|
-
// Apply xfg templating if enabled
|
|
161
|
-
let contentToWrite = file.content;
|
|
162
|
-
if (file.template && contentToWrite !== null) {
|
|
163
|
-
contentToWrite = interpolateXfgContent(contentToWrite, {
|
|
164
|
-
repoInfo,
|
|
165
|
-
fileName: file.fileName,
|
|
166
|
-
vars: file.vars,
|
|
167
|
-
}, { strict: true });
|
|
168
|
-
}
|
|
169
|
-
const fileContent = convertContentToString(contentToWrite, file.fileName, {
|
|
170
|
-
header: file.header,
|
|
171
|
-
schemaUrl: file.schemaUrl,
|
|
172
|
-
});
|
|
173
|
-
// Determine action type (create vs update) BEFORE writing
|
|
174
|
-
const action = fileExistsLocal
|
|
175
|
-
? "update"
|
|
176
|
-
: "create";
|
|
177
|
-
// Check if file would change (needed for both modes)
|
|
178
|
-
const existingContent = this.gitOps.getFileContent(file.fileName);
|
|
179
|
-
const changed = this.gitOps.wouldChange(file.fileName, fileContent);
|
|
180
|
-
if (changed) {
|
|
181
|
-
// Track in single source of truth
|
|
182
|
-
fileChangesForCommit.set(file.fileName, {
|
|
183
|
-
content: fileContent,
|
|
184
|
-
action,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
if (dryRun) {
|
|
188
|
-
// In dry-run, show diff but don't write
|
|
189
|
-
const status = getFileStatus(existingContent !== null, changed);
|
|
190
|
-
incrementDiffStats(diffStats, status);
|
|
191
|
-
const diffLines = generateDiff(existingContent, fileContent, file.fileName);
|
|
192
|
-
this.log.fileDiff(file.fileName, status, diffLines);
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
// Write the file
|
|
196
|
-
this.gitOps.writeFile(file.fileName, fileContent);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
// Step 5b: Set executable permission for files that need it
|
|
200
|
-
for (const file of repoConfig.files) {
|
|
201
|
-
// Skip files that were excluded (createOnly + exists)
|
|
202
|
-
const tracked = fileChangesForCommit.get(file.fileName);
|
|
203
|
-
if (tracked?.action === "skip") {
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
if (shouldBeExecutable(file)) {
|
|
207
|
-
this.log.info(`Setting executable: ${file.fileName}`);
|
|
208
|
-
await this.gitOps.setExecutable(file.fileName);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
// Step 5c: Handle orphaned file deletion (manifest-based tracking)
|
|
212
|
-
const existingManifest = loadManifest(workDir);
|
|
213
|
-
// Build map of files with their deleteOrphaned setting
|
|
214
|
-
// Include ALL files from config, even skipped ones (createOnly + exists),
|
|
215
|
-
// so they aren't incorrectly treated as orphaned (issue #199)
|
|
216
|
-
const filesWithDeleteOrphaned = new Map();
|
|
217
|
-
for (const file of repoConfig.files) {
|
|
218
|
-
filesWithDeleteOrphaned.set(file.fileName, file.deleteOrphaned);
|
|
219
|
-
}
|
|
220
|
-
// Update manifest and get list of files to delete
|
|
221
|
-
const { manifest: newManifest, filesToDelete } = updateManifest(existingManifest, options.configId, filesWithDeleteOrphaned);
|
|
222
|
-
// Delete orphaned files (unless --no-delete flag is set)
|
|
223
|
-
if (filesToDelete.length > 0 && !options.noDelete) {
|
|
224
|
-
for (const fileName of filesToDelete) {
|
|
225
|
-
// Only delete if file actually exists in the working directory
|
|
226
|
-
if (this.gitOps.fileExists(fileName)) {
|
|
227
|
-
// Track deletion in single source of truth
|
|
228
|
-
fileChangesForCommit.set(fileName, {
|
|
229
|
-
content: null,
|
|
230
|
-
action: "delete",
|
|
231
|
-
});
|
|
232
|
-
if (dryRun) {
|
|
233
|
-
// In dry-run, show what would be deleted
|
|
234
|
-
this.log.fileDiff(fileName, "DELETED", []);
|
|
235
|
-
incrementDiffStats(diffStats, "DELETED");
|
|
236
|
-
}
|
|
237
|
-
else {
|
|
238
|
-
this.log.info(`Deleting orphaned file: ${fileName}`);
|
|
239
|
-
this.gitOps.deleteFile(fileName);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
else if (filesToDelete.length > 0 && options.noDelete) {
|
|
245
|
-
this.log.info(`Skipping deletion of ${filesToDelete.length} orphaned file(s) (--no-delete flag)`);
|
|
246
|
-
}
|
|
247
|
-
// Save updated manifest (tracks files with deleteOrphaned: true)
|
|
248
|
-
// Only save if there are managed files for any config, or if we had a previous manifest
|
|
249
|
-
const hasAnyManagedFiles = Object.keys(newManifest.configs).length > 0;
|
|
250
|
-
if (hasAnyManagedFiles || existingManifest !== null) {
|
|
251
|
-
// Track manifest file as changed if it would be different
|
|
252
|
-
const existingConfigs = existingManifest?.configs ?? {};
|
|
253
|
-
const manifestChanged = JSON.stringify(existingConfigs) !==
|
|
254
|
-
JSON.stringify(newManifest.configs);
|
|
255
|
-
if (manifestChanged) {
|
|
256
|
-
const manifestExisted = existsSync(join(workDir, MANIFEST_FILENAME));
|
|
257
|
-
const manifestContent = JSON.stringify(newManifest, null, 2) + "\n";
|
|
258
|
-
fileChangesForCommit.set(MANIFEST_FILENAME, {
|
|
259
|
-
content: manifestContent,
|
|
260
|
-
action: manifestExisted ? "update" : "create",
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
if (!dryRun) {
|
|
264
|
-
saveManifest(workDir, newManifest);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
// Show diff summary in dry-run mode
|
|
268
|
-
if (dryRun) {
|
|
269
|
-
this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount, diffStats.deletedCount);
|
|
270
|
-
}
|
|
271
|
-
// Step 6: Derive changedFiles from single source of truth
|
|
272
|
-
// This ensures dry-run and non-dry-run modes use identical logic
|
|
273
|
-
const changedFiles = Array.from(fileChangesForCommit.entries()).map(([fileName, info]) => ({ fileName, action: info.action }));
|
|
274
|
-
// Calculate diff stats for non-dry-run mode (dry-run already calculated above)
|
|
275
|
-
if (!dryRun) {
|
|
276
|
-
for (const [, info] of fileChangesForCommit) {
|
|
277
|
-
if (info.action === "create")
|
|
278
|
-
incrementDiffStats(diffStats, "NEW");
|
|
279
|
-
else if (info.action === "update")
|
|
280
|
-
incrementDiffStats(diffStats, "MODIFIED");
|
|
281
|
-
else if (info.action === "delete")
|
|
282
|
-
incrementDiffStats(diffStats, "DELETED");
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
const hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
|
|
286
|
-
if (!hasChanges) {
|
|
287
|
-
return {
|
|
288
|
-
success: true,
|
|
289
|
-
repoName,
|
|
290
|
-
message: "No changes detected",
|
|
291
|
-
skipped: true,
|
|
292
|
-
diffStats,
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
// Step 7: Commit and Push using commit strategy
|
|
296
|
-
const commitMessage = this.formatCommitMessage(changedFiles);
|
|
297
|
-
const pushBranch = isDirectMode ? baseBranch : branchName;
|
|
298
|
-
if (dryRun) {
|
|
299
|
-
// In dry-run mode, just log what would happen
|
|
300
|
-
this.log.info("Staging changes...");
|
|
301
|
-
this.log.info(`Would commit: ${commitMessage}`);
|
|
302
|
-
this.log.info(`Would push to ${pushBranch}...`);
|
|
303
|
-
}
|
|
304
|
-
else {
|
|
305
|
-
// Build file changes for commit strategy (filter out skipped files)
|
|
306
|
-
const fileChanges = Array.from(fileChangesForCommit.entries())
|
|
307
|
-
.filter(([, info]) => info.action !== "skip")
|
|
308
|
-
.map(([path, info]) => ({ path, content: info.content }));
|
|
309
|
-
// Check if there are actually staged changes (edge case handling)
|
|
310
|
-
// This handles scenarios where git status shows changes but git add doesn't stage anything
|
|
311
|
-
// (e.g., due to .gitattributes normalization)
|
|
312
|
-
this.log.info("Staging changes...");
|
|
313
|
-
await this.executor.exec("git add -A", workDir);
|
|
314
|
-
if (!(await this.gitOps.hasStagedChanges())) {
|
|
315
|
-
this.log.info("No staged changes after git add -A, skipping commit");
|
|
316
|
-
return {
|
|
317
|
-
success: true,
|
|
318
|
-
repoName,
|
|
319
|
-
message: "No changes detected after staging",
|
|
320
|
-
skipped: true,
|
|
321
|
-
diffStats,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
// Use commit strategy (GitCommitStrategy or GraphQLCommitStrategy)
|
|
325
|
-
const commitStrategy = getCommitStrategy(repoInfo, this.executor);
|
|
326
|
-
this.log.info("Committing and pushing changes...");
|
|
327
|
-
try {
|
|
328
|
-
const commitResult = await commitStrategy.commit({
|
|
329
|
-
repoInfo,
|
|
330
|
-
branchName: pushBranch,
|
|
331
|
-
message: commitMessage,
|
|
332
|
-
fileChanges,
|
|
333
|
-
workDir,
|
|
334
|
-
retries: this.retries,
|
|
335
|
-
// Use force push (--force-with-lease) for PR branches, not for direct mode
|
|
336
|
-
force: !isDirectMode,
|
|
337
|
-
token,
|
|
338
|
-
gitOps: this.gitOps,
|
|
339
|
-
});
|
|
340
|
-
this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
|
|
341
|
-
}
|
|
342
|
-
catch (error) {
|
|
343
|
-
// Handle branch protection errors in direct mode
|
|
344
|
-
if (isDirectMode) {
|
|
345
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
346
|
-
if (errorMessage.includes("rejected") ||
|
|
347
|
-
errorMessage.includes("protected") ||
|
|
348
|
-
errorMessage.includes("denied")) {
|
|
349
|
-
return {
|
|
350
|
-
success: false,
|
|
351
|
-
repoName,
|
|
352
|
-
message: `Push to '${baseBranch}' was rejected (likely branch protection). To use 'direct' mode, the target branch must allow direct pushes. Use 'merge: force' to create a PR and merge with admin privileges.`,
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
throw error;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
// Direct mode: no PR creation, return success
|
|
360
|
-
if (isDirectMode) {
|
|
361
|
-
this.log.info(`Changes pushed directly to ${baseBranch}`);
|
|
362
|
-
return {
|
|
363
|
-
success: true,
|
|
364
|
-
repoName,
|
|
365
|
-
message: `Pushed directly to ${baseBranch}`,
|
|
366
|
-
diffStats,
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
// Step 9: Create PR (non-direct modes only)
|
|
370
|
-
this.log.info("Creating pull request...");
|
|
371
|
-
const prResult = await createPR({
|
|
372
|
-
repoInfo,
|
|
373
|
-
branchName,
|
|
374
|
-
baseBranch,
|
|
375
|
-
files: changedFiles,
|
|
376
|
-
workDir,
|
|
377
|
-
dryRun,
|
|
378
|
-
retries: this.retries,
|
|
379
|
-
prTemplate,
|
|
380
|
-
executor: this.executor,
|
|
381
|
-
token,
|
|
382
|
-
});
|
|
383
|
-
// Step 10: Handle merge options if configured
|
|
384
|
-
let mergeResult;
|
|
385
|
-
if (prResult.success && prResult.url && mergeMode !== "manual") {
|
|
386
|
-
this.log.info(`Handling merge (mode: ${mergeMode})...`);
|
|
387
|
-
const mergeConfig = {
|
|
388
|
-
mode: mergeMode,
|
|
389
|
-
strategy: repoConfig.prOptions?.mergeStrategy ?? "squash",
|
|
390
|
-
deleteBranch: repoConfig.prOptions?.deleteBranch ?? true,
|
|
391
|
-
bypassReason: repoConfig.prOptions?.bypassReason,
|
|
392
|
-
};
|
|
393
|
-
const result = await mergePR({
|
|
394
|
-
repoInfo,
|
|
395
|
-
prUrl: prResult.url,
|
|
396
|
-
mergeConfig,
|
|
397
|
-
workDir,
|
|
398
|
-
dryRun,
|
|
399
|
-
retries: this.retries,
|
|
400
|
-
executor: this.executor,
|
|
401
|
-
token,
|
|
402
|
-
});
|
|
403
|
-
mergeResult = {
|
|
404
|
-
merged: result.merged ?? false,
|
|
405
|
-
autoMergeEnabled: result.autoMergeEnabled,
|
|
406
|
-
message: result.message,
|
|
407
|
-
};
|
|
408
|
-
if (!result.success) {
|
|
409
|
-
this.log.info(`Warning: Merge operation failed - ${result.message}`);
|
|
410
|
-
}
|
|
411
|
-
else {
|
|
412
|
-
this.log.info(result.message);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
return {
|
|
416
|
-
success: prResult.success,
|
|
417
|
-
repoName,
|
|
418
|
-
message: prResult.message,
|
|
419
|
-
prUrl: prResult.url,
|
|
420
|
-
mergeResult,
|
|
421
|
-
diffStats,
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
finally {
|
|
425
|
-
// Always cleanup workspace on completion or failure
|
|
426
|
-
if (this.gitOps) {
|
|
427
|
-
try {
|
|
428
|
-
this.gitOps.cleanWorkspace();
|
|
429
|
-
}
|
|
430
|
-
catch {
|
|
431
|
-
// Ignore cleanup errors - best effort
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* Gets installation token for GitHub repos when GitHub App is configured.
|
|
438
|
-
* Returns undefined if no token needed or token retrieval fails.
|
|
439
|
-
* Returns null if no installation found (caller should skip repo).
|
|
440
|
-
*/
|
|
441
|
-
async getInstallationToken(repoInfo) {
|
|
442
|
-
if (!this.tokenManager || !isGitHubRepo(repoInfo)) {
|
|
443
|
-
return undefined;
|
|
444
|
-
}
|
|
445
|
-
try {
|
|
446
|
-
return await this.tokenManager.getTokenForRepo(repoInfo);
|
|
447
|
-
}
|
|
448
|
-
catch (error) {
|
|
449
|
-
this.log.info(`Warning: Failed to get GitHub App token: ${error instanceof Error ? error.message : String(error)}`);
|
|
450
|
-
return undefined;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* Updates only the manifest file with ruleset tracking.
|
|
455
|
-
* Used by settings command to persist state for deleteOrphaned.
|
|
456
|
-
* Reuses existing clone/commit/PR workflow.
|
|
457
|
-
*/
|
|
458
|
-
async updateManifestOnly(repoInfo, repoConfig, options, manifestUpdate) {
|
|
459
|
-
const repoName = getRepoDisplayName(repoInfo);
|
|
460
|
-
const { branchName, workDir, dryRun } = options;
|
|
461
|
-
this.retries = options.retries ?? 3;
|
|
462
|
-
this.executor = options.executor ?? defaultExecutor;
|
|
463
|
-
// Get installation token if needed
|
|
464
|
-
const token = await this.getInstallationToken(repoInfo);
|
|
465
|
-
if (token === null) {
|
|
466
|
-
return {
|
|
467
|
-
success: true,
|
|
468
|
-
repoName,
|
|
469
|
-
message: `No GitHub App installation found for ${repoInfo.owner}`,
|
|
470
|
-
skipped: true,
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
// Build auth options - use installation token OR fall back to GH_TOKEN for PAT flow
|
|
474
|
-
const effectiveToken = token ?? (isGitHubRepo(repoInfo) ? process.env.GH_TOKEN : undefined);
|
|
475
|
-
const authOptions = effectiveToken
|
|
476
|
-
? {
|
|
477
|
-
token: effectiveToken,
|
|
478
|
-
host: isGitHubRepo(repoInfo)
|
|
479
|
-
? repoInfo.host
|
|
480
|
-
: "github.com",
|
|
481
|
-
owner: repoInfo.owner,
|
|
482
|
-
repo: repoInfo.repo,
|
|
483
|
-
}
|
|
484
|
-
: undefined;
|
|
485
|
-
this.gitOps = this.gitOpsFactory({
|
|
486
|
-
workDir,
|
|
487
|
-
dryRun,
|
|
488
|
-
retries: this.retries,
|
|
489
|
-
}, authOptions);
|
|
490
|
-
const mergeMode = repoConfig.prOptions?.merge ?? "auto";
|
|
491
|
-
const isDirectMode = mergeMode === "direct";
|
|
492
|
-
try {
|
|
493
|
-
// Clone repo and get base branch
|
|
494
|
-
this.log.info("Cleaning workspace...");
|
|
495
|
-
this.gitOps.cleanWorkspace();
|
|
496
|
-
this.log.info("Cloning repository...");
|
|
497
|
-
await this.gitOps.clone(repoInfo.gitUrl);
|
|
498
|
-
const { branch: baseBranch } = await this.gitOps.getDefaultBranch();
|
|
499
|
-
// Load and update manifest
|
|
500
|
-
const existingManifest = loadManifest(workDir);
|
|
501
|
-
const rulesetsWithDeleteOrphaned = new Map(manifestUpdate.rulesets.map((name) => [name, true]));
|
|
502
|
-
const { manifest: newManifest } = updateManifestRulesets(existingManifest, options.configId, rulesetsWithDeleteOrphaned);
|
|
503
|
-
// Check if manifest changed
|
|
504
|
-
const existingConfigs = existingManifest?.configs ?? {};
|
|
505
|
-
if (JSON.stringify(existingConfigs) === JSON.stringify(newManifest.configs)) {
|
|
506
|
-
return {
|
|
507
|
-
success: true,
|
|
508
|
-
repoName,
|
|
509
|
-
message: "No manifest changes detected",
|
|
510
|
-
skipped: true,
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
// Dry-run mode: report what would happen
|
|
514
|
-
if (dryRun) {
|
|
515
|
-
this.log.info(`Would update ${MANIFEST_FILENAME} with rulesets`);
|
|
516
|
-
return {
|
|
517
|
-
success: true,
|
|
518
|
-
repoName,
|
|
519
|
-
message: "Would update manifest (dry-run)",
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
// Prepare branch for commit
|
|
523
|
-
if (!isDirectMode) {
|
|
524
|
-
const strategy = getPRStrategy(repoInfo, this.executor);
|
|
525
|
-
if (await strategy.closeExistingPR({
|
|
526
|
-
repoInfo,
|
|
527
|
-
branchName,
|
|
528
|
-
baseBranch,
|
|
529
|
-
workDir,
|
|
530
|
-
retries: this.retries,
|
|
531
|
-
token,
|
|
532
|
-
})) {
|
|
533
|
-
await this.gitOps.fetch({ prune: true });
|
|
534
|
-
}
|
|
535
|
-
await this.gitOps.createBranch(branchName);
|
|
536
|
-
}
|
|
537
|
-
// Save manifest and commit
|
|
538
|
-
saveManifest(workDir, newManifest);
|
|
539
|
-
await this.executor.exec("git add -A", workDir);
|
|
540
|
-
if (!(await this.gitOps.hasStagedChanges())) {
|
|
541
|
-
return {
|
|
542
|
-
success: true,
|
|
543
|
-
repoName,
|
|
544
|
-
message: "No changes detected after staging",
|
|
545
|
-
skipped: true,
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
const pushBranch = isDirectMode ? baseBranch : branchName;
|
|
549
|
-
const commitStrategy = getCommitStrategy(repoInfo, this.executor);
|
|
550
|
-
try {
|
|
551
|
-
await commitStrategy.commit({
|
|
552
|
-
repoInfo,
|
|
553
|
-
branchName: pushBranch,
|
|
554
|
-
message: "chore: update manifest with ruleset tracking",
|
|
555
|
-
fileChanges: [
|
|
556
|
-
{
|
|
557
|
-
path: MANIFEST_FILENAME,
|
|
558
|
-
content: JSON.stringify(newManifest, null, 2) + "\n",
|
|
559
|
-
},
|
|
560
|
-
],
|
|
561
|
-
workDir,
|
|
562
|
-
retries: this.retries,
|
|
563
|
-
force: !isDirectMode,
|
|
564
|
-
token,
|
|
565
|
-
gitOps: this.gitOps,
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
catch (error) {
|
|
569
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
570
|
-
if (isDirectMode &&
|
|
571
|
-
(msg.includes("rejected") ||
|
|
572
|
-
msg.includes("protected") ||
|
|
573
|
-
msg.includes("denied"))) {
|
|
574
|
-
return {
|
|
575
|
-
success: false,
|
|
576
|
-
repoName,
|
|
577
|
-
message: `Push to '${baseBranch}' was rejected (likely branch protection).`,
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
throw error;
|
|
581
|
-
}
|
|
582
|
-
if (isDirectMode) {
|
|
583
|
-
return {
|
|
584
|
-
success: true,
|
|
585
|
-
repoName,
|
|
586
|
-
message: `Manifest updated directly on ${baseBranch}`,
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
// Create PR and handle merge
|
|
590
|
-
const prResult = await createPR({
|
|
591
|
-
repoInfo,
|
|
592
|
-
branchName,
|
|
593
|
-
baseBranch,
|
|
594
|
-
files: [{ fileName: MANIFEST_FILENAME, action: "update" }],
|
|
595
|
-
workDir,
|
|
596
|
-
dryRun: false,
|
|
597
|
-
retries: this.retries,
|
|
598
|
-
executor: this.executor,
|
|
599
|
-
token,
|
|
600
|
-
});
|
|
601
|
-
if (prResult.success && prResult.url && mergeMode !== "manual") {
|
|
602
|
-
await mergePR({
|
|
603
|
-
repoInfo,
|
|
604
|
-
prUrl: prResult.url,
|
|
605
|
-
mergeConfig: {
|
|
606
|
-
mode: mergeMode,
|
|
607
|
-
strategy: repoConfig.prOptions?.mergeStrategy ?? "squash",
|
|
608
|
-
deleteBranch: repoConfig.prOptions?.deleteBranch ?? true,
|
|
609
|
-
},
|
|
610
|
-
workDir,
|
|
611
|
-
dryRun: false,
|
|
612
|
-
retries: this.retries,
|
|
613
|
-
executor: this.executor,
|
|
614
|
-
token,
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
return {
|
|
618
|
-
success: prResult.success,
|
|
619
|
-
repoName,
|
|
620
|
-
message: prResult.message,
|
|
621
|
-
prUrl: prResult.url,
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
finally {
|
|
625
|
-
if (this.gitOps) {
|
|
626
|
-
try {
|
|
627
|
-
this.gitOps.cleanWorkspace();
|
|
628
|
-
}
|
|
629
|
-
catch {
|
|
630
|
-
// Ignore cleanup errors
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
/**
|
|
636
|
-
* Format commit message based on files changed (excludes skipped files)
|
|
637
|
-
*/
|
|
638
|
-
formatCommitMessage(files) {
|
|
639
|
-
const changedFiles = files.filter((f) => f.action !== "skip");
|
|
640
|
-
const deletedFiles = changedFiles.filter((f) => f.action === "delete");
|
|
641
|
-
const syncedFiles = changedFiles.filter((f) => f.action !== "delete");
|
|
642
|
-
// If only deletions, use "remove" prefix
|
|
643
|
-
if (syncedFiles.length === 0 && deletedFiles.length > 0) {
|
|
644
|
-
if (deletedFiles.length === 1) {
|
|
645
|
-
return `chore: remove ${deletedFiles[0].fileName}`;
|
|
646
|
-
}
|
|
647
|
-
return `chore: remove ${deletedFiles.length} orphaned config files`;
|
|
648
|
-
}
|
|
649
|
-
// Mixed or only syncs
|
|
650
|
-
if (changedFiles.length === 1) {
|
|
651
|
-
return `chore: sync ${changedFiles[0].fileName}`;
|
|
652
|
-
}
|
|
653
|
-
if (changedFiles.length <= 3) {
|
|
654
|
-
const fileNames = changedFiles.map((f) => f.fileName).join(", ");
|
|
655
|
-
return `chore: sync ${fileNames}`;
|
|
656
|
-
}
|
|
657
|
-
return `chore: sync ${changedFiles.length} config files`;
|
|
658
|
-
}
|
|
659
|
-
}
|