@aspruyt/xfg 3.1.3 → 3.1.5

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.
@@ -0,0 +1,119 @@
1
+ import { GitOps } from "./git-ops.js";
2
+ /**
3
+ * Options for authenticated git operations.
4
+ */
5
+ export interface GitAuthOptions {
6
+ /** Access token for authentication */
7
+ token: string;
8
+ /** Git host (e.g., "github.com", "github.mycompany.com") */
9
+ host: string;
10
+ /** Repository owner */
11
+ owner: string;
12
+ /** Repository name */
13
+ repo: string;
14
+ }
15
+ /**
16
+ * Interface for authenticated git operations.
17
+ * Enables proper mocking in tests without relying on class inheritance.
18
+ */
19
+ export interface IAuthenticatedGitOps {
20
+ clone(gitUrl: string): Promise<void>;
21
+ fetch(options?: {
22
+ prune?: boolean;
23
+ }): Promise<void>;
24
+ push(branchName: string, options?: {
25
+ force?: boolean;
26
+ }): Promise<void>;
27
+ getDefaultBranch(): Promise<{
28
+ branch: string;
29
+ method: string;
30
+ }>;
31
+ lsRemote(branchName: string, options?: {
32
+ skipRetry?: boolean;
33
+ }): Promise<string>;
34
+ pushRefspec(refspec: string, options?: {
35
+ delete?: boolean;
36
+ }): Promise<void>;
37
+ fetchBranch(branchName: string): Promise<void>;
38
+ cleanWorkspace(): void;
39
+ createBranch(branchName: string): Promise<void>;
40
+ writeFile(fileName: string, content: string): void;
41
+ setExecutable(fileName: string): Promise<void>;
42
+ getFileContent(fileName: string): string | null;
43
+ wouldChange(fileName: string, content: string): boolean;
44
+ hasChanges(): Promise<boolean>;
45
+ getChangedFiles(): Promise<string[]>;
46
+ hasStagedChanges(): Promise<boolean>;
47
+ fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
48
+ fileExists(fileName: string): boolean;
49
+ deleteFile(fileName: string): void;
50
+ commit(message: string): Promise<boolean>;
51
+ }
52
+ /**
53
+ * Wrapper around GitOps that adds authentication to network operations.
54
+ *
55
+ * When auth options are provided, network operations (clone, fetch, push,
56
+ * getDefaultBranch) use `-c url.insteadOf` to override credentials per-command.
57
+ * This allows different tokens for different repos without global git config.
58
+ *
59
+ * Local operations (commit, writeFile, etc.) pass through unchanged.
60
+ */
61
+ export declare class AuthenticatedGitOps implements IAuthenticatedGitOps {
62
+ private gitOps;
63
+ private auth?;
64
+ private executor;
65
+ private workDir;
66
+ private retries;
67
+ constructor(gitOps: GitOps, auth?: GitAuthOptions);
68
+ private execWithRetry;
69
+ /**
70
+ * Build the authenticated remote URL.
71
+ */
72
+ private getAuthenticatedUrl;
73
+ clone(gitUrl: string): Promise<void>;
74
+ fetch(options?: {
75
+ prune?: boolean;
76
+ }): Promise<void>;
77
+ push(branchName: string, options?: {
78
+ force?: boolean;
79
+ }): Promise<void>;
80
+ getDefaultBranch(): Promise<{
81
+ branch: string;
82
+ method: string;
83
+ }>;
84
+ /**
85
+ * Execute ls-remote with authentication.
86
+ * Used by GraphQLCommitStrategy to check if branch exists on remote.
87
+ *
88
+ * @param options.skipRetry - If true, don't retry on failure. Use when checking
89
+ * branch existence where failure is expected for new branches.
90
+ */
91
+ lsRemote(branchName: string, options?: {
92
+ skipRetry?: boolean;
93
+ }): Promise<string>;
94
+ /**
95
+ * Execute push with custom refspec (e.g., HEAD:branchName).
96
+ * Used by GraphQLCommitStrategy for creating/deleting remote branches.
97
+ */
98
+ pushRefspec(refspec: string, options?: {
99
+ delete?: boolean;
100
+ }): Promise<void>;
101
+ /**
102
+ * Fetch a specific branch from remote.
103
+ * Used by GraphQLCommitStrategy to update local refs.
104
+ */
105
+ fetchBranch(branchName: string): Promise<void>;
106
+ cleanWorkspace(): void;
107
+ createBranch(branchName: string): Promise<void>;
108
+ writeFile(fileName: string, content: string): void;
109
+ setExecutable(fileName: string): Promise<void>;
110
+ getFileContent(fileName: string): string | null;
111
+ wouldChange(fileName: string, content: string): boolean;
112
+ hasChanges(): Promise<boolean>;
113
+ getChangedFiles(): Promise<string[]>;
114
+ hasStagedChanges(): Promise<boolean>;
115
+ fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
116
+ fileExists(fileName: string): boolean;
117
+ deleteFile(fileName: string): void;
118
+ commit(message: string): Promise<boolean>;
119
+ }
@@ -0,0 +1,177 @@
1
+ import { escapeShellArg } from "./shell-utils.js";
2
+ import { defaultExecutor } from "./command-executor.js";
3
+ import { withRetry } from "./retry-utils.js";
4
+ /**
5
+ * Wrapper around GitOps that adds authentication to network operations.
6
+ *
7
+ * When auth options are provided, network operations (clone, fetch, push,
8
+ * getDefaultBranch) use `-c url.insteadOf` to override credentials per-command.
9
+ * This allows different tokens for different repos without global git config.
10
+ *
11
+ * Local operations (commit, writeFile, etc.) pass through unchanged.
12
+ */
13
+ export class AuthenticatedGitOps {
14
+ gitOps;
15
+ auth;
16
+ executor;
17
+ workDir;
18
+ retries;
19
+ constructor(gitOps, auth) {
20
+ this.gitOps = gitOps;
21
+ this.auth = auth;
22
+ // Extract executor and workDir from gitOps via reflection
23
+ const internal = gitOps;
24
+ this.executor = internal.executor ?? defaultExecutor;
25
+ this.workDir = internal.workDir ?? ".";
26
+ this.retries = internal.retries ?? 3;
27
+ }
28
+ async execWithRetry(command) {
29
+ return withRetry(() => this.executor.exec(command, this.workDir), {
30
+ retries: this.retries,
31
+ });
32
+ }
33
+ // ============================================================
34
+ // Network operations - use authenticated command when token provided
35
+ // ============================================================
36
+ /**
37
+ * Build the authenticated remote URL.
38
+ */
39
+ getAuthenticatedUrl() {
40
+ const { token, host, owner, repo } = this.auth;
41
+ return `https://x-access-token:${token}@${host}/${owner}/${repo}`;
42
+ }
43
+ async clone(gitUrl) {
44
+ if (!this.auth) {
45
+ return this.gitOps.clone(gitUrl);
46
+ }
47
+ // Clone using authenticated URL directly - no insteadOf needed
48
+ const authUrl = escapeShellArg(this.getAuthenticatedUrl());
49
+ await this.execWithRetry(`git clone ${authUrl} .`);
50
+ }
51
+ async fetch(options) {
52
+ if (!this.auth) {
53
+ return this.gitOps.fetch(options);
54
+ }
55
+ // Remote URL already has auth from clone, just fetch
56
+ const pruneFlag = options?.prune ? " --prune" : "";
57
+ await this.execWithRetry(`git fetch origin${pruneFlag}`);
58
+ }
59
+ async push(branchName, options) {
60
+ if (!this.auth) {
61
+ return this.gitOps.push(branchName, options);
62
+ }
63
+ // Remote URL already has auth from clone, just push
64
+ const forceFlag = options?.force ? "--force-with-lease " : "";
65
+ const safeBranch = escapeShellArg(branchName);
66
+ await this.execWithRetry(`git push ${forceFlag}-u origin ${safeBranch}`);
67
+ }
68
+ async getDefaultBranch() {
69
+ if (!this.auth) {
70
+ return this.gitOps.getDefaultBranch();
71
+ }
72
+ // Network operation - remote URL already has auth from clone
73
+ try {
74
+ const remoteInfo = await this.execWithRetry(`git remote show origin`);
75
+ const match = remoteInfo.match(/HEAD branch: (\S+)/);
76
+ if (match) {
77
+ return { branch: match[1], method: "remote HEAD" };
78
+ }
79
+ }
80
+ catch {
81
+ // Fall through to local checks
82
+ }
83
+ // Local operations don't need auth
84
+ try {
85
+ await this.executor.exec("git rev-parse --verify origin/main", this.workDir);
86
+ return { branch: "main", method: "origin/main exists" };
87
+ }
88
+ catch {
89
+ // Continue
90
+ }
91
+ try {
92
+ await this.executor.exec("git rev-parse --verify origin/master", this.workDir);
93
+ return { branch: "master", method: "origin/master exists" };
94
+ }
95
+ catch {
96
+ // Continue
97
+ }
98
+ return { branch: "main", method: "fallback default" };
99
+ }
100
+ /**
101
+ * Execute ls-remote with authentication.
102
+ * Used by GraphQLCommitStrategy to check if branch exists on remote.
103
+ *
104
+ * @param options.skipRetry - If true, don't retry on failure. Use when checking
105
+ * branch existence where failure is expected for new branches.
106
+ */
107
+ async lsRemote(branchName, options) {
108
+ // Remote URL already has auth from clone
109
+ const safeBranch = escapeShellArg(branchName);
110
+ const command = `git ls-remote --exit-code --heads origin ${safeBranch}`;
111
+ if (options?.skipRetry) {
112
+ return this.executor.exec(command, this.workDir);
113
+ }
114
+ return this.execWithRetry(command);
115
+ }
116
+ /**
117
+ * Execute push with custom refspec (e.g., HEAD:branchName).
118
+ * Used by GraphQLCommitStrategy for creating/deleting remote branches.
119
+ */
120
+ async pushRefspec(refspec, options) {
121
+ // Remote URL already has auth from clone
122
+ const deleteFlag = options?.delete ? "--delete " : "";
123
+ const safeRefspec = escapeShellArg(refspec);
124
+ await this.execWithRetry(`git push ${deleteFlag}-u origin ${safeRefspec}`);
125
+ }
126
+ /**
127
+ * Fetch a specific branch from remote.
128
+ * Used by GraphQLCommitStrategy to update local refs.
129
+ */
130
+ async fetchBranch(branchName) {
131
+ // Remote URL already has auth from clone
132
+ const safeBranch = escapeShellArg(branchName);
133
+ await this.execWithRetry(`git fetch origin ${safeBranch}:refs/remotes/origin/${safeBranch}`);
134
+ }
135
+ // ============================================================
136
+ // Local operations - delegate directly to GitOps
137
+ // ============================================================
138
+ cleanWorkspace() {
139
+ return this.gitOps.cleanWorkspace();
140
+ }
141
+ async createBranch(branchName) {
142
+ return this.gitOps.createBranch(branchName);
143
+ }
144
+ writeFile(fileName, content) {
145
+ return this.gitOps.writeFile(fileName, content);
146
+ }
147
+ async setExecutable(fileName) {
148
+ return this.gitOps.setExecutable(fileName);
149
+ }
150
+ getFileContent(fileName) {
151
+ return this.gitOps.getFileContent(fileName);
152
+ }
153
+ wouldChange(fileName, content) {
154
+ return this.gitOps.wouldChange(fileName, content);
155
+ }
156
+ async hasChanges() {
157
+ return this.gitOps.hasChanges();
158
+ }
159
+ async getChangedFiles() {
160
+ return this.gitOps.getChangedFiles();
161
+ }
162
+ async hasStagedChanges() {
163
+ return this.gitOps.hasStagedChanges();
164
+ }
165
+ async fileExistsOnBranch(fileName, branch) {
166
+ return this.gitOps.fileExistsOnBranch(fileName, branch);
167
+ }
168
+ fileExists(fileName) {
169
+ return this.gitOps.fileExists(fileName);
170
+ }
171
+ deleteFile(fileName) {
172
+ return this.gitOps.deleteFile(fileName);
173
+ }
174
+ async commit(message) {
175
+ return this.gitOps.commit(message);
176
+ }
177
+ }
@@ -2,7 +2,7 @@
2
2
  * Interface for executing shell commands.
3
3
  * Enables dependency injection for testing and alternative implementations.
4
4
  */
5
- export interface CommandExecutor {
5
+ export interface ICommandExecutor {
6
6
  /**
7
7
  * Execute a shell command and return the output.
8
8
  * @param command The command to execute
@@ -16,10 +16,10 @@ export interface CommandExecutor {
16
16
  * Default implementation that uses Node.js child_process.execSync.
17
17
  * Note: Commands are escaped using escapeShellArg before being passed here.
18
18
  */
19
- export declare class ShellCommandExecutor implements CommandExecutor {
19
+ export declare class ShellCommandExecutor implements ICommandExecutor {
20
20
  exec(command: string, cwd: string): Promise<string>;
21
21
  }
22
22
  /**
23
23
  * Default executor instance for production use.
24
24
  */
25
- export declare const defaultExecutor: CommandExecutor;
25
+ export declare const defaultExecutor: ICommandExecutor;
package/dist/git-ops.d.ts CHANGED
@@ -1,12 +1,38 @@
1
- import { CommandExecutor } from "./command-executor.js";
1
+ import { ICommandExecutor } from "./command-executor.js";
2
+ export interface IGitOps {
3
+ cleanWorkspace(): void;
4
+ clone(gitUrl: string): Promise<void>;
5
+ fetch(options?: {
6
+ prune?: boolean;
7
+ }): Promise<void>;
8
+ createBranch(branchName: string): Promise<void>;
9
+ commit(message: string): Promise<boolean>;
10
+ push(branchName: string, options?: {
11
+ force?: boolean;
12
+ }): Promise<void>;
13
+ getDefaultBranch(): Promise<{
14
+ branch: string;
15
+ method: string;
16
+ }>;
17
+ writeFile(fileName: string, content: string): void;
18
+ setExecutable(fileName: string): Promise<void>;
19
+ getFileContent(fileName: string): string | null;
20
+ deleteFile(fileName: string): void;
21
+ wouldChange(fileName: string, content: string): boolean;
22
+ hasChanges(): Promise<boolean>;
23
+ getChangedFiles(): Promise<string[]>;
24
+ hasStagedChanges(): Promise<boolean>;
25
+ fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
26
+ fileExists(fileName: string): boolean;
27
+ }
2
28
  export interface GitOpsOptions {
3
29
  workDir: string;
4
30
  dryRun?: boolean;
5
- executor?: CommandExecutor;
31
+ executor?: ICommandExecutor;
6
32
  /** Number of retries for network operations (default: 3) */
7
33
  retries?: number;
8
34
  }
9
- export declare class GitOps {
35
+ export declare class GitOps implements IGitOps {
10
36
  private workDir;
11
37
  private dryRun;
12
38
  private executor;
package/dist/logger.d.ts CHANGED
@@ -3,6 +3,13 @@ export interface ILogger {
3
3
  info(message: string): void;
4
4
  fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
5
5
  diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
6
+ setTotal(total: number): void;
7
+ progress(current: number, repoName: string, message: string): void;
8
+ success(current: number, repoName: string, message: string): void;
9
+ skip(current: number, repoName: string, reason: string): void;
10
+ error(current: number, repoName: string, error: string): void;
11
+ summary(): void;
12
+ hasFailures(): boolean;
6
13
  }
7
14
  export interface LoggerStats {
8
15
  total: number;
@@ -10,7 +17,7 @@ export interface LoggerStats {
10
17
  failed: number;
11
18
  skipped: number;
12
19
  }
13
- export declare class Logger {
20
+ export declare class Logger implements ILogger {
14
21
  private stats;
15
22
  setTotal(total: number): void;
16
23
  progress(current: number, repoName: string, message: string): void;
@@ -1,6 +1,6 @@
1
1
  import { RepoInfo } from "./repo-detector.js";
2
2
  import { MergeResult, PRMergeConfig } from "./strategies/index.js";
3
- import { CommandExecutor } from "./command-executor.js";
3
+ import { ICommandExecutor } from "./command-executor.js";
4
4
  export { escapeShellArg } from "./shell-utils.js";
5
5
  export interface FileAction {
6
6
  fileName: string;
@@ -18,7 +18,7 @@ export interface PROptions {
18
18
  /** Custom PR body template */
19
19
  prTemplate?: string;
20
20
  /** Optional command executor for shell commands (for testing) */
21
- executor?: CommandExecutor;
21
+ executor?: ICommandExecutor;
22
22
  /** GitHub App installation token for authentication */
23
23
  token?: string;
24
24
  }
@@ -51,7 +51,7 @@ export interface MergePROptions {
51
51
  dryRun?: boolean;
52
52
  retries?: number;
53
53
  /** Optional command executor for shell commands (for testing) */
54
- executor?: CommandExecutor;
54
+ executor?: ICommandExecutor;
55
55
  /** GitHub App installation token for authentication */
56
56
  token?: string;
57
57
  }
@@ -1,9 +1,16 @@
1
1
  import { RepoConfig } from "./config.js";
2
2
  import { RepoInfo } from "./repo-detector.js";
3
- import { GitOps, GitOpsOptions } from "./git-ops.js";
3
+ import { GitOpsOptions } from "./git-ops.js";
4
+ import { IAuthenticatedGitOps, GitAuthOptions } from "./authenticated-git-ops.js";
4
5
  import { ILogger } from "./logger.js";
5
- import { CommandExecutor } from "./command-executor.js";
6
+ import { ICommandExecutor } from "./command-executor.js";
6
7
  import { DiffStats } from "./diff-utils.js";
8
+ export interface IRepositoryProcessor {
9
+ process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
10
+ updateManifestOnly(repoInfo: RepoInfo, repoConfig: RepoConfig, options: ProcessorOptions, manifestUpdate: {
11
+ rulesets: string[];
12
+ }): Promise<ProcessorResult>;
13
+ }
7
14
  export interface ProcessorOptions {
8
15
  branchName: string;
9
16
  workDir: string;
@@ -13,17 +20,17 @@ export interface ProcessorOptions {
13
20
  /** Number of retries for network operations (default: 3) */
14
21
  retries?: number;
15
22
  /** Command executor for shell commands (for testing) */
16
- executor?: CommandExecutor;
23
+ executor?: ICommandExecutor;
17
24
  /** Custom PR body template */
18
25
  prTemplate?: string;
19
26
  /** Skip deleting orphaned files even if deleteOrphaned is configured */
20
27
  noDelete?: boolean;
21
28
  }
22
29
  /**
23
- * Factory function type for creating GitOps instances.
30
+ * Factory function type for creating IAuthenticatedGitOps instances.
24
31
  * Allows dependency injection for testing.
25
32
  */
26
- export type GitOpsFactory = (options: GitOpsOptions) => GitOps;
33
+ export type GitOpsFactory = (options: GitOpsOptions, auth?: GitAuthOptions) => IAuthenticatedGitOps;
27
34
  export interface ProcessorResult {
28
35
  success: boolean;
29
36
  repoName: string;
@@ -37,7 +44,7 @@ export interface ProcessorResult {
37
44
  };
38
45
  diffStats?: DiffStats;
39
46
  }
40
- export declare class RepositoryProcessor {
47
+ export declare class RepositoryProcessor implements IRepositoryProcessor {
41
48
  private gitOps;
42
49
  private readonly gitOpsFactory;
43
50
  private readonly log;
@@ -46,7 +53,7 @@ export declare class RepositoryProcessor {
46
53
  private readonly tokenManager;
47
54
  /**
48
55
  * Creates a new RepositoryProcessor.
49
- * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
56
+ * @param gitOpsFactory - Optional factory for creating AuthenticatedGitOps instances (for testing)
50
57
  * @param log - Optional logger instance (for testing)
51
58
  */
52
59
  constructor(gitOpsFactory?: GitOpsFactory, log?: ILogger);
@@ -4,6 +4,7 @@ import { convertContentToString, } from "./config.js";
4
4
  import { getRepoDisplayName, isGitHubRepo, } from "./repo-detector.js";
5
5
  import { interpolateXfgContent } from "./xfg-template.js";
6
6
  import { GitOps } from "./git-ops.js";
7
+ import { AuthenticatedGitOps, } from "./authenticated-git-ops.js";
7
8
  import { createPR, mergePR } from "./pr-creator.js";
8
9
  import { logger } from "./logger.js";
9
10
  import { getPRStrategy, getCommitStrategy, hasGitHubAppCredentials, } from "./strategies/index.js";
@@ -34,11 +35,13 @@ export class RepositoryProcessor {
34
35
  tokenManager;
35
36
  /**
36
37
  * Creates a new RepositoryProcessor.
37
- * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
38
+ * @param gitOpsFactory - Optional factory for creating AuthenticatedGitOps instances (for testing)
38
39
  * @param log - Optional logger instance (for testing)
39
40
  */
40
41
  constructor(gitOpsFactory, log) {
41
- this.gitOpsFactory = gitOpsFactory ?? ((opts) => new GitOps(opts));
42
+ this.gitOpsFactory =
43
+ gitOpsFactory ??
44
+ ((opts, auth) => new AuthenticatedGitOps(new GitOps(opts), auth));
42
45
  this.log = log ?? logger;
43
46
  // Initialize GitHub App token manager if credentials are configured
44
47
  if (hasGitHubAppCredentials()) {
@@ -63,11 +66,23 @@ export class RepositoryProcessor {
63
66
  skipped: true,
64
67
  };
65
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;
66
81
  this.gitOps = this.gitOpsFactory({
67
82
  workDir,
68
83
  dryRun,
69
84
  retries: this.retries,
70
- });
85
+ }, authOptions);
71
86
  // Determine merge mode early - affects workflow steps
72
87
  const mergeMode = repoConfig.prOptions?.merge ?? "auto";
73
88
  const isDirectMode = mergeMode === "direct";
@@ -320,6 +335,7 @@ export class RepositoryProcessor {
320
335
  // Use force push (--force-with-lease) for PR branches, not for direct mode
321
336
  force: !isDirectMode,
322
337
  token,
338
+ gitOps: this.gitOps,
323
339
  });
324
340
  this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
325
341
  }
@@ -454,11 +470,23 @@ export class RepositoryProcessor {
454
470
  skipped: true,
455
471
  };
456
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;
457
485
  this.gitOps = this.gitOpsFactory({
458
486
  workDir,
459
487
  dryRun,
460
488
  retries: this.retries,
461
- });
489
+ }, authOptions);
462
490
  const mergeMode = repoConfig.prOptions?.merge ?? "auto";
463
491
  const isDirectMode = mergeMode === "direct";
464
492
  try {
@@ -534,6 +562,7 @@ export class RepositoryProcessor {
534
562
  retries: this.retries,
535
563
  force: !isDirectMode,
536
564
  token,
565
+ gitOps: this.gitOps,
537
566
  });
538
567
  }
539
568
  catch (error) {
@@ -1,6 +1,9 @@
1
1
  import type { RepoConfig } from "./config.js";
2
2
  import type { RepoInfo } from "./repo-detector.js";
3
3
  import { GitHubRulesetStrategy } from "./strategies/github-ruleset-strategy.js";
4
+ export interface IRulesetProcessor {
5
+ process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: RulesetProcessorOptions): Promise<RulesetProcessorResult>;
6
+ }
4
7
  export interface RulesetProcessorOptions {
5
8
  configId: string;
6
9
  dryRun?: boolean;
@@ -28,7 +31,7 @@ export interface RulesetProcessorResult {
28
31
  * Processes ruleset configuration for a repository.
29
32
  * Handles create/update/delete operations via GitHub Rulesets API.
30
33
  */
31
- export declare class RulesetProcessor {
34
+ export declare class RulesetProcessor implements IRulesetProcessor {
32
35
  private readonly strategy;
33
36
  constructor(strategy?: GitHubRulesetStrategy);
34
37
  /**
@@ -1,8 +1,8 @@
1
1
  import { PRResult } from "../pr-creator.js";
2
2
  import { BasePRStrategy, PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./pr-strategy.js";
3
- import { CommandExecutor } from "../command-executor.js";
3
+ import { ICommandExecutor } from "../command-executor.js";
4
4
  export declare class AzurePRStrategy extends BasePRStrategy {
5
- constructor(executor?: CommandExecutor);
5
+ constructor(executor?: ICommandExecutor);
6
6
  private getOrgUrl;
7
7
  private buildPRUrl;
8
8
  checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
@@ -1,6 +1,6 @@
1
1
  import { RepoInfo } from "../repo-detector.js";
2
- import { CommitStrategy } from "./commit-strategy.js";
3
- import { CommandExecutor } from "../command-executor.js";
2
+ import { ICommitStrategy } from "./commit-strategy.js";
3
+ import { ICommandExecutor } from "../command-executor.js";
4
4
  /**
5
5
  * Checks if GitHub App credentials are configured via environment variables.
6
6
  * Both XFG_GITHUB_APP_ID and XFG_GITHUB_APP_PRIVATE_KEY must be set.
@@ -19,4 +19,4 @@ export declare function hasGitHubAppCredentials(): boolean;
19
19
  * @param repoInfo - Repository information
20
20
  * @param executor - Optional command executor for shell commands
21
21
  */
22
- export declare function getCommitStrategy(repoInfo: RepoInfo, executor?: CommandExecutor): CommitStrategy;
22
+ export declare function getCommitStrategy(repoInfo: RepoInfo, executor?: ICommandExecutor): ICommitStrategy;
@@ -1,4 +1,5 @@
1
1
  import { RepoInfo } from "../repo-detector.js";
2
+ import { IAuthenticatedGitOps } from "../authenticated-git-ops.js";
2
3
  export interface FileChange {
3
4
  path: string;
4
5
  content: string | null;
@@ -14,6 +15,8 @@ export interface CommitOptions {
14
15
  force?: boolean;
15
16
  /** GitHub App installation token for authentication (used by GraphQLCommitStrategy) */
16
17
  token?: string;
18
+ /** Authenticated git operations wrapper (used by GraphQLCommitStrategy for network ops) */
19
+ gitOps?: IAuthenticatedGitOps;
17
20
  }
18
21
  export interface CommitResult {
19
22
  sha: string;
@@ -24,7 +27,7 @@ export interface CommitResult {
24
27
  * Strategy interface for creating commits.
25
28
  * Implementations handle platform-specific commit mechanisms.
26
29
  */
27
- export interface CommitStrategy {
30
+ export interface ICommitStrategy {
28
31
  /**
29
32
  * Create a commit with the given file changes and push to remote.
30
33
  * @returns Commit result with SHA and verification status
@@ -1,13 +1,13 @@
1
- import { CommitStrategy, CommitOptions, CommitResult } from "./commit-strategy.js";
2
- import { CommandExecutor } from "../command-executor.js";
1
+ import { ICommitStrategy, CommitOptions, CommitResult } from "./commit-strategy.js";
2
+ import { ICommandExecutor } from "../command-executor.js";
3
3
  /**
4
4
  * Git-based commit strategy using standard git commands (add, commit, push).
5
5
  * Used with PAT authentication. Commits via this strategy are NOT verified
6
6
  * by GitHub (no signature).
7
7
  */
8
- export declare class GitCommitStrategy implements CommitStrategy {
8
+ export declare class GitCommitStrategy implements ICommitStrategy {
9
9
  private executor;
10
- constructor(executor?: CommandExecutor);
10
+ constructor(executor?: ICommandExecutor);
11
11
  /**
12
12
  * Create a commit with the given file changes and push to remote.
13
13
  * Runs: git add -A, git commit, git push (with optional --force-with-lease)
@@ -18,18 +18,23 @@ export class GitCommitStrategy {
18
18
  * @returns Commit result with SHA and verified: false (no signature)
19
19
  */
20
20
  async commit(options) {
21
- const { branchName, message, workDir, retries = 3, force = true } = options;
21
+ const { branchName, message, workDir, retries = 3, force = true, gitOps, } = options;
22
22
  // Stage all changes
23
23
  await this.executor.exec("git add -A", workDir);
24
24
  // Commit with the message (--no-verify to skip pre-commit hooks)
25
25
  await this.executor.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, workDir);
26
- // Build push command - use --force-with-lease for PR branches, regular push for direct mode
27
- const forceFlag = force ? "--force-with-lease " : "";
28
- const pushCommand = `git push ${forceFlag}-u origin ${escapeShellArg(branchName)}`;
29
- // Push with retry for transient network failures
30
- await withRetry(() => this.executor.exec(pushCommand, workDir), {
31
- retries,
32
- });
26
+ // Push with authentication via gitOps if available
27
+ if (gitOps) {
28
+ await gitOps.push(branchName, { force });
29
+ }
30
+ else {
31
+ // Fallback for non-authenticated scenarios (shouldn't happen in practice)
32
+ const forceFlag = force ? "--force-with-lease " : "";
33
+ const pushCommand = `git push ${forceFlag}-u origin ${escapeShellArg(branchName)}`;
34
+ await withRetry(() => this.executor.exec(pushCommand, workDir), {
35
+ retries,
36
+ });
37
+ }
33
38
  // Get the commit SHA
34
39
  const sha = await this.executor.exec("git rev-parse HEAD", workDir);
35
40
  return {
@@ -1,6 +1,7 @@
1
- import { CommandExecutor } from "../command-executor.js";
1
+ import { ICommandExecutor } from "../command-executor.js";
2
2
  import { RepoInfo } from "../repo-detector.js";
3
3
  import type { Ruleset } from "../config.js";
4
+ import type { IRulesetStrategy } from "./ruleset-strategy.js";
4
5
  /**
5
6
  * GitHub Ruleset response from API (snake_case).
6
7
  */
@@ -50,9 +51,9 @@ export interface RulesetStrategyOptions {
50
51
  * GitHub Ruleset Strategy for managing repository rulesets via GitHub REST API.
51
52
  * Uses `gh api` CLI for authentication and API calls.
52
53
  */
53
- export declare class GitHubRulesetStrategy {
54
+ export declare class GitHubRulesetStrategy implements IRulesetStrategy {
54
55
  private executor;
55
- constructor(executor?: CommandExecutor);
56
+ constructor(executor?: ICommandExecutor);
56
57
  /**
57
58
  * Lists all rulesets for a repository.
58
59
  */
@@ -1,8 +1,8 @@
1
1
  import { PRResult } from "../pr-creator.js";
2
2
  import { BasePRStrategy, PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./pr-strategy.js";
3
- import { CommandExecutor } from "../command-executor.js";
3
+ import { ICommandExecutor } from "../command-executor.js";
4
4
  export declare class GitLabPRStrategy extends BasePRStrategy {
5
- constructor(executor?: CommandExecutor);
5
+ constructor(executor?: ICommandExecutor);
6
6
  /**
7
7
  * Build the repo flag for glab commands.
8
8
  * Format: namespace/repo (supports nested groups)
@@ -1,5 +1,5 @@
1
- import { CommitStrategy, CommitOptions, CommitResult } from "./commit-strategy.js";
2
- import { CommandExecutor } from "../command-executor.js";
1
+ import { ICommitStrategy, CommitOptions, CommitResult } from "./commit-strategy.js";
2
+ import { ICommandExecutor } from "../command-executor.js";
3
3
  /**
4
4
  * Maximum payload size for GitHub GraphQL API (50MB).
5
5
  * Base64 encoding adds ~33% overhead, so raw content should be checked.
@@ -29,9 +29,9 @@ export declare function validateBranchName(branchName: string): void;
29
29
  *
30
30
  * This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
31
31
  */
32
- export declare class GraphQLCommitStrategy implements CommitStrategy {
32
+ export declare class GraphQLCommitStrategy implements ICommitStrategy {
33
33
  private executor;
34
- constructor(executor?: CommandExecutor);
34
+ constructor(executor?: ICommandExecutor);
35
35
  /**
36
36
  * Create a commit with the given file changes using GitHub's GraphQL API.
37
37
  * Uses the createCommitOnBranch mutation for verified commits.
@@ -44,20 +44,6 @@ export declare class GraphQLCommitStrategy implements CommitStrategy {
44
44
  * Execute the createCommitOnBranch GraphQL mutation.
45
45
  */
46
46
  private executeGraphQLMutation;
47
- /**
48
- * Build a git command with optional token authentication override.
49
- * When a token is provided, uses -c url.insteadOf to override the global
50
- * git config and authenticate with the provided token instead.
51
- *
52
- * This is critical for GitHub App authentication where the global git config
53
- * may have a PAT token embedded, but we need to use the GitHub App installation token.
54
- *
55
- * Uses a repo-specific URL pattern (including owner/repo) so it has a LONGER
56
- * prefix match than the global config and takes precedence.
57
- *
58
- * Applies to all remote operations: push, fetch, ls-remote, etc.
59
- */
60
- private buildAuthenticatedGitCommand;
61
47
  /**
62
48
  * Ensure the branch exists on the remote and matches local HEAD.
63
49
  * createCommitOnBranch requires the branch to already exist.
@@ -69,10 +69,12 @@ export class GraphQLCommitStrategy {
69
69
  throw new Error(`GraphQL payload exceeds 50 MB limit (${Math.round(totalSize / (1024 * 1024))} MB). ` +
70
70
  `Consider using smaller files or the git commit strategy.`);
71
71
  }
72
+ // Get gitOps for authenticated network operations
73
+ const gitOps = options.gitOps;
72
74
  // Ensure the branch exists on remote and is up-to-date with local HEAD
73
75
  // createCommitOnBranch requires the branch to already exist
74
76
  // For PR branches (force=true), we force-update to ensure fresh start from main
75
- await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, token, githubInfo.host, githubInfo.owner, githubInfo.repo);
77
+ await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, gitOps);
76
78
  // Retry loop for expectedHeadOid mismatch
77
79
  let lastError = null;
78
80
  for (let attempt = 0; attempt <= retries; attempt++) {
@@ -80,7 +82,12 @@ export class GraphQLCommitStrategy {
80
82
  // Fetch from remote to ensure we have the latest HEAD
81
83
  // This is critical for expectedHeadOid to match
82
84
  const safeBranch = escapeShellArg(branchName);
83
- await this.executor.exec(this.buildAuthenticatedGitCommand(`fetch origin ${safeBranch}:refs/remotes/origin/${safeBranch}`, token, githubInfo.host, githubInfo.owner, githubInfo.repo), workDir);
85
+ if (gitOps) {
86
+ await gitOps.fetchBranch(branchName);
87
+ }
88
+ else {
89
+ await this.executor.exec(`git fetch origin ${safeBranch}:refs/remotes/origin/${safeBranch}`, workDir);
90
+ }
84
91
  // Get the remote HEAD SHA for this branch (not local HEAD)
85
92
  const headSha = await this.executor.exec(`git rev-parse origin/${safeBranch}`, workDir);
86
93
  // Build and execute the GraphQL mutation
@@ -171,31 +178,6 @@ export class GraphQLCommitStrategy {
171
178
  pushed: true, // GraphQL commits are pushed directly
172
179
  };
173
180
  }
174
- /**
175
- * Build a git command with optional token authentication override.
176
- * When a token is provided, uses -c url.insteadOf to override the global
177
- * git config and authenticate with the provided token instead.
178
- *
179
- * This is critical for GitHub App authentication where the global git config
180
- * may have a PAT token embedded, but we need to use the GitHub App installation token.
181
- *
182
- * Uses a repo-specific URL pattern (including owner/repo) so it has a LONGER
183
- * prefix match than the global config and takes precedence.
184
- *
185
- * Applies to all remote operations: push, fetch, ls-remote, etc.
186
- */
187
- buildAuthenticatedGitCommand(gitArgs, token, host = "github.com", owner, repo) {
188
- if (!token) {
189
- return `git ${gitArgs}`;
190
- }
191
- // Use repo-specific URL pattern for LONGER prefix match to override global config
192
- // Global config: url."https://x-access-token:PAT@github.com/".insteadOf = "https://github.com/"
193
- // Our config: url."https://x-access-token:APP@github.com/owner/repo".insteadOf = "https://github.com/owner/repo"
194
- // The longer prefix (owner/repo) takes precedence in git's URL matching
195
- const repoPath = owner && repo ? `${owner}/${repo}` : "";
196
- const urlOverride = `url."https://x-access-token:${token}@${host}/${repoPath}".insteadOf="https://${host}/${repoPath}"`;
197
- return `git -c ${escapeShellArg(urlOverride)} ${gitArgs}`;
198
- }
199
181
  /**
200
182
  * Ensure the branch exists on the remote and matches local HEAD.
201
183
  * createCommitOnBranch requires the branch to already exist.
@@ -205,23 +187,41 @@ export class GraphQLCommitStrategy {
205
187
  *
206
188
  * For direct mode (force=false): just ensure branch exists.
207
189
  */
208
- async ensureBranchExistsOnRemote(branchName, workDir, force, token, host, owner, repo) {
190
+ async ensureBranchExistsOnRemote(branchName, workDir, force, gitOps) {
209
191
  // Branch name was validated in commit(), safe for shell use
210
192
  try {
211
193
  // Check if the branch exists on remote
212
- await this.executor.exec(this.buildAuthenticatedGitCommand(`ls-remote --exit-code --heads origin ${escapeShellArg(branchName)}`, token, host, owner, repo), workDir);
194
+ // Use skipRetry because failure is expected for new branches
195
+ if (gitOps) {
196
+ await gitOps.lsRemote(branchName, { skipRetry: true });
197
+ }
198
+ else {
199
+ await this.executor.exec(`git ls-remote --exit-code --heads origin ${escapeShellArg(branchName)}`, workDir);
200
+ }
213
201
  // Branch exists - for PR branches, delete and recreate to ensure fresh from main
214
202
  if (force) {
215
- await this.executor.exec(this.buildAuthenticatedGitCommand(`push origin --delete ${escapeShellArg(branchName)}`, token, host, owner, repo), workDir);
216
- // Now push fresh branch from local HEAD
217
- await this.executor.exec(this.buildAuthenticatedGitCommand(`push -u origin HEAD:${escapeShellArg(branchName)}`, token, host, owner, repo), workDir);
203
+ if (gitOps) {
204
+ await gitOps.pushRefspec(branchName, { delete: true });
205
+ // Now push fresh branch from local HEAD
206
+ await gitOps.pushRefspec(`HEAD:${branchName}`);
207
+ }
208
+ else {
209
+ await this.executor.exec(`git push origin --delete ${escapeShellArg(branchName)}`, workDir);
210
+ // Now push fresh branch from local HEAD
211
+ await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
212
+ }
218
213
  }
219
214
  // For direct mode (force=false), leave existing branch as-is
220
215
  }
221
216
  catch {
222
217
  // Branch doesn't exist on remote, push it
223
218
  // This pushes the current local branch to create it on remote
224
- await this.executor.exec(this.buildAuthenticatedGitCommand(`push -u origin HEAD:${escapeShellArg(branchName)}`, token, host, owner, repo), workDir);
219
+ if (gitOps) {
220
+ await gitOps.pushRefspec(`HEAD:${branchName}`);
221
+ }
222
+ else {
223
+ await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
224
+ }
225
225
  }
226
226
  }
227
227
  /**
@@ -1,12 +1,12 @@
1
1
  import { RepoInfo } from "../repo-detector.js";
2
- import type { PRStrategy } from "./pr-strategy.js";
3
- import { CommandExecutor } from "../command-executor.js";
4
- export type { PRStrategy, PRStrategyOptions, CloseExistingPROptions, PRMergeConfig, MergeOptions, MergeResult, } from "./pr-strategy.js";
2
+ import type { IPRStrategy } from "./pr-strategy.js";
3
+ import { ICommandExecutor } from "../command-executor.js";
4
+ export type { IPRStrategy, PRStrategyOptions, CloseExistingPROptions, PRMergeConfig, MergeOptions, MergeResult, } from "./pr-strategy.js";
5
5
  export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
6
6
  export { GitHubPRStrategy } from "./github-pr-strategy.js";
7
7
  export { AzurePRStrategy } from "./azure-pr-strategy.js";
8
8
  export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
9
- export type { CommitStrategy, CommitOptions, CommitResult, FileChange, } from "./commit-strategy.js";
9
+ export type { ICommitStrategy, CommitOptions, CommitResult, FileChange, } from "./commit-strategy.js";
10
10
  export { GitCommitStrategy } from "./git-commit-strategy.js";
11
11
  export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
12
12
  export { getCommitStrategy, hasGitHubAppCredentials, } from "./commit-strategy-selector.js";
@@ -15,4 +15,4 @@ export { getCommitStrategy, hasGitHubAppCredentials, } from "./commit-strategy-s
15
15
  * @param repoInfo - Repository information
16
16
  * @param executor - Optional command executor for shell commands
17
17
  */
18
- export declare function getPRStrategy(repoInfo: RepoInfo, executor?: CommandExecutor): PRStrategy;
18
+ export declare function getPRStrategy(repoInfo: RepoInfo, executor?: ICommandExecutor): IPRStrategy;
@@ -1,6 +1,6 @@
1
1
  import { PRResult } from "../pr-creator.js";
2
2
  import { RepoInfo } from "../repo-detector.js";
3
- import { CommandExecutor } from "../command-executor.js";
3
+ import { ICommandExecutor } from "../command-executor.js";
4
4
  import type { MergeMode, MergeStrategy } from "../config.js";
5
5
  export interface PRMergeConfig {
6
6
  mode: MergeMode;
@@ -51,7 +51,7 @@ export interface CloseExistingPROptions {
51
51
  * Strategies focus on platform-specific logic (checkExistingPR, create, merge).
52
52
  * Use PRWorkflowExecutor for full workflow orchestration with error handling.
53
53
  */
54
- export interface PRStrategy {
54
+ export interface IPRStrategy {
55
55
  /**
56
56
  * Check if a PR already exists for the given branch
57
57
  * @returns PR URL if exists, null otherwise
@@ -79,10 +79,10 @@ export interface PRStrategy {
79
79
  */
80
80
  execute(options: PRStrategyOptions): Promise<PRResult>;
81
81
  }
82
- export declare abstract class BasePRStrategy implements PRStrategy {
82
+ export declare abstract class BasePRStrategy implements IPRStrategy {
83
83
  protected bodyFilePath: string;
84
- protected executor: CommandExecutor;
85
- constructor(executor?: CommandExecutor);
84
+ protected executor: ICommandExecutor;
85
+ constructor(executor?: ICommandExecutor);
86
86
  abstract checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
87
87
  abstract closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
88
88
  abstract create(options: PRStrategyOptions): Promise<PRResult>;
@@ -110,7 +110,7 @@ export declare abstract class BasePRStrategy implements PRStrategy {
110
110
  */
111
111
  export declare class PRWorkflowExecutor {
112
112
  private readonly strategy;
113
- constructor(strategy: PRStrategy);
113
+ constructor(strategy: IPRStrategy);
114
114
  /**
115
115
  * Execute the full PR creation workflow with error handling.
116
116
  */
@@ -0,0 +1,10 @@
1
+ import type { RepoInfo } from "../repo-detector.js";
2
+ import type { Ruleset } from "../config.js";
3
+ import type { GitHubRuleset, RulesetStrategyOptions } from "./github-ruleset-strategy.js";
4
+ export interface IRulesetStrategy {
5
+ list(repoInfo: RepoInfo, options?: RulesetStrategyOptions): Promise<GitHubRuleset[]>;
6
+ get(repoInfo: RepoInfo, rulesetId: number, options?: RulesetStrategyOptions): Promise<GitHubRuleset>;
7
+ create(repoInfo: RepoInfo, name: string, ruleset: Ruleset, options?: RulesetStrategyOptions): Promise<GitHubRuleset>;
8
+ update(repoInfo: RepoInfo, rulesetId: number, name: string, ruleset: Ruleset, options?: RulesetStrategyOptions): Promise<GitHubRuleset>;
9
+ delete(repoInfo: RepoInfo, rulesetId: number, options?: RulesetStrategyOptions): Promise<void>;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.1.3",
3
+ "version": "3.1.5",
4
4
  "description": "CLI tool for repository-as-code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -27,7 +27,7 @@
27
27
  "start": "node dist/cli.js",
28
28
  "dev": "ts-node src/cli.ts",
29
29
  "test": "node --import tsx scripts/run-tests.js",
30
- "test:coverage": "c8 --check-coverage --lines 95 --reporter=text --reporter=lcov --all --src=src --exclude='src/**/*.test.ts' --exclude='scripts/**' npm test",
30
+ "test:coverage": "c8 --check-coverage --lines 95 --reporter=text --reporter=lcov --all --src=src --exclude='test/**/*.test.ts' --exclude='scripts/**' npm test",
31
31
  "test:integration:github": "npm run build && node --import tsx --test test/integration/github.test.ts",
32
32
  "test:integration:ado": "npm run build && node --import tsx --test test/integration/ado.test.ts",
33
33
  "test:integration:gitlab": "npm run build && node --import tsx --test test/integration/gitlab.test.ts",