@samaksh/git-worker 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/GIT_WORKER_PROJECT_PLAN.md +499 -0
  2. package/README.md +114 -2
  3. package/demo/README.md +58 -0
  4. package/demo/index.ts +526 -0
  5. package/dist/app.d.ts +2 -0
  6. package/dist/app.js +48 -0
  7. package/dist/cli/commands.d.ts +6 -0
  8. package/dist/cli/commands.js +111 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +115 -0
  11. package/dist/config/defaults.d.ts +2 -0
  12. package/dist/config/defaults.js +44 -0
  13. package/dist/config/load.d.ts +3 -0
  14. package/dist/config/load.js +65 -0
  15. package/dist/config/schema.d.ts +200 -0
  16. package/dist/config/schema.js +45 -0
  17. package/dist/core/errors.d.ts +12 -0
  18. package/dist/core/errors.js +34 -0
  19. package/dist/core/pipeline.d.ts +3 -0
  20. package/dist/core/pipeline.js +14 -0
  21. package/dist/core/types.d.ts +153 -0
  22. package/dist/core/types.js +16 -0
  23. package/dist/services/editor/index.d.ts +9 -0
  24. package/dist/services/editor/index.js +59 -0
  25. package/dist/services/git/ops.d.ts +30 -0
  26. package/dist/services/git/ops.js +139 -0
  27. package/dist/services/git/reader.d.ts +14 -0
  28. package/dist/services/git/reader.js +124 -0
  29. package/dist/services/report/index.d.ts +18 -0
  30. package/dist/services/report/index.js +68 -0
  31. package/dist/services/review/index.d.ts +30 -0
  32. package/dist/services/review/index.js +59 -0
  33. package/dist/services/state/index.d.ts +20 -0
  34. package/dist/services/state/index.js +49 -0
  35. package/dist/services/template/env.d.ts +23 -0
  36. package/dist/services/template/env.js +146 -0
  37. package/dist/services/template/interpolator.d.ts +30 -0
  38. package/dist/services/template/interpolator.js +75 -0
  39. package/dist/services/template/transform.d.ts +22 -0
  40. package/dist/services/template/transform.js +75 -0
  41. package/dist/services/template/validator.d.ts +5 -0
  42. package/dist/services/template/validator.js +68 -0
  43. package/dist/services/workspace/cache.d.ts +32 -0
  44. package/dist/services/workspace/cache.js +107 -0
  45. package/dist/services/workspace/manager.d.ts +44 -0
  46. package/dist/services/workspace/manager.js +301 -0
  47. package/dist/stages/apply.d.ts +3 -0
  48. package/dist/stages/apply.js +161 -0
  49. package/dist/stages/edit-template.d.ts +3 -0
  50. package/dist/stages/edit-template.js +198 -0
  51. package/dist/stages/eligibility.d.ts +3 -0
  52. package/dist/stages/eligibility.js +44 -0
  53. package/dist/stages/load-template.d.ts +3 -0
  54. package/dist/stages/load-template.js +168 -0
  55. package/dist/stages/plan.d.ts +3 -0
  56. package/dist/stages/plan.js +270 -0
  57. package/dist/stages/push.d.ts +3 -0
  58. package/dist/stages/push.js +99 -0
  59. package/dist/stages/report.d.ts +3 -0
  60. package/dist/stages/report.js +103 -0
  61. package/dist/stages/resolve-project.d.ts +3 -0
  62. package/dist/stages/resolve-project.js +294 -0
  63. package/dist/stages/review.d.ts +3 -0
  64. package/dist/stages/review.js +197 -0
  65. package/dist/stages/select.d.ts +13 -0
  66. package/dist/stages/select.js +250 -0
  67. package/dist/stages/sync.d.ts +3 -0
  68. package/dist/stages/sync.js +47 -0
  69. package/dist/utils/hash.d.ts +2 -0
  70. package/dist/utils/hash.js +7 -0
  71. package/dist/utils/ui.d.ts +21 -0
  72. package/dist/utils/ui.js +113 -0
  73. package/dist/utils/which.d.ts +5 -0
  74. package/dist/utils/which.js +16 -0
  75. package/docs/FEATURES.md +84 -0
  76. package/docs/SERVICES.md +57 -0
  77. package/docs/architecture.md +38 -0
  78. package/docs/usage.md +337 -0
  79. package/package.json +48 -4
  80. package/src/app.ts +69 -0
  81. package/src/cli/commands.ts +142 -0
  82. package/src/cli/index.ts +123 -0
  83. package/src/config/defaults.ts +46 -0
  84. package/src/config/load.ts +70 -0
  85. package/src/config/schema.ts +48 -0
  86. package/src/core/errors.ts +45 -0
  87. package/src/core/pipeline.ts +17 -0
  88. package/src/core/types.ts +188 -0
  89. package/src/services/editor/index.ts +85 -0
  90. package/src/services/git/ops.ts +170 -0
  91. package/src/services/git/reader.ts +148 -0
  92. package/src/services/report/index.ts +104 -0
  93. package/src/services/review/index.ts +80 -0
  94. package/src/services/state/index.ts +77 -0
  95. package/src/services/template/env.ts +169 -0
  96. package/src/services/template/interpolator.ts +109 -0
  97. package/src/services/template/transform.ts +97 -0
  98. package/src/services/template/validator.ts +75 -0
  99. package/src/services/workspace/cache.ts +144 -0
  100. package/src/services/workspace/manager.ts +370 -0
  101. package/src/stages/apply.ts +194 -0
  102. package/src/stages/edit-template.ts +227 -0
  103. package/src/stages/eligibility.ts +67 -0
  104. package/src/stages/load-template.ts +188 -0
  105. package/src/stages/plan.ts +362 -0
  106. package/src/stages/push.ts +120 -0
  107. package/src/stages/report.ts +126 -0
  108. package/src/stages/resolve-project.ts +341 -0
  109. package/src/stages/review.ts +228 -0
  110. package/src/stages/select.ts +302 -0
  111. package/src/stages/sync.ts +63 -0
  112. package/src/utils/hash.ts +15 -0
  113. package/src/utils/ui.ts +137 -0
  114. package/src/utils/which.ts +18 -0
  115. package/tsconfig.json +18 -0
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { run } from "../app.js";
4
+ import { handleCacheList, handleCacheClean } from "./commands.js";
5
+ import chalk from "chalk";
6
+
7
+ const program = new Command();
8
+
9
+ program
10
+ .name("git-worker")
11
+ .description("Safe, composable CLI/TUI for applying reviewed file changes across many Git branches")
12
+ .version("0.1.0")
13
+ .option("-r, --repo <path>", "Repository path or remote URL (default: current directory)")
14
+ .option("-c, --cached", "Select from previously cloned and cached repositories")
15
+ .option("-b, --branch <pattern>", "Branch pattern — glob (uat/*) or regex (/uat.*/)")
16
+ .option("-i, --invert-branch", "Invert the branch selection pattern")
17
+ .option("-f, --file <path>", "Target file path inside the repository (e.g. .env)")
18
+ .option("-e, --editor <name>", "Editor to use: code (VS Code) or vim")
19
+ .option("-m, --commit-msg <message>", "Commit message")
20
+ .option("-p, --push <policy>", "Push policy: prompt (default) | always | never")
21
+ .option("--remote <name>", "Git remote name (default: origin)")
22
+ .option("--auto-approve", "Auto-approve all changes (skip interactive review)")
23
+ .option("--ci", "CI mode: non-interactive, fail on missing dynamic values")
24
+ .action(async (opts) => {
25
+ // Validate push policy
26
+ if (opts.push && !["prompt", "always", "never"].includes(opts.push)) {
27
+ console.error(chalk.red(` ✗ Invalid push policy '${opts.push}'. Must be: prompt | always | never`));
28
+ process.exit(1);
29
+ }
30
+
31
+ // Validate incompatible combinations
32
+ if (opts.ci && opts.push === "prompt") {
33
+ console.error(chalk.yellow(` ⚠ Warning: --ci with --push prompt will not push (no interactive prompt in CI). Use --push always or --push never.`));
34
+ }
35
+
36
+ try {
37
+ const result = await run({
38
+ repo: opts.repo,
39
+ cached: opts.cached ?? false,
40
+ branch: opts.branch,
41
+ invertBranch: opts.invertBranch ?? false,
42
+ file: opts.file,
43
+ editor: opts.editor,
44
+ commitMsg: opts.commitMsg,
45
+ push: opts.push as "prompt" | "always" | "never" | undefined,
46
+ remote: opts.remote,
47
+ autoApprove: opts.autoApprove ?? false,
48
+ ci: opts.ci ?? false,
49
+ });
50
+
51
+ if (!result.ok) {
52
+ const { error } = result;
53
+ if (error.category === "INPUT_CANCELLED") {
54
+ console.log(chalk.gray("\n Cancelled."));
55
+ process.exit(0);
56
+ }
57
+ console.error(chalk.red(`\n ✗ [${error.category}] ${error.message}`));
58
+ process.exit(1);
59
+ }
60
+
61
+ process.exit(0);
62
+ } catch (e: any) {
63
+ console.error(chalk.red(`\n ✗ Fatal: ${e.message}`));
64
+ if (process.env.DEBUG) {
65
+ console.error(e.stack);
66
+ }
67
+ process.exit(1);
68
+ }
69
+ });
70
+
71
+ // ── Cache management subcommands ──────────────────────────────────────────
72
+
73
+ const cacheCmd = program
74
+ .command("cache")
75
+ .description("Manage git-worker's cached workspaces and repositories (~/.git-worker)");
76
+
77
+ cacheCmd
78
+ .command("list")
79
+ .alias("ls")
80
+ .description("List all cached projects")
81
+ .action(async () => {
82
+ try {
83
+ await handleCacheList();
84
+ process.exit(0);
85
+ } catch (e: any) {
86
+ console.error(chalk.red(` ✗ Error: ${e.message}`));
87
+ process.exit(1);
88
+ }
89
+ });
90
+
91
+ cacheCmd
92
+ .command("clean [target]")
93
+ .alias("clear")
94
+ .description("Clean workspace cache (all or a specific project)")
95
+ .option("-a, --all", "Clean all cached projects")
96
+ .option("-y, --yes", "Skip confirmation prompt")
97
+ .action(async (target, opts) => {
98
+ try {
99
+ await handleCacheClean(target, opts);
100
+ process.exit(0);
101
+ } catch (e: any) {
102
+ console.error(chalk.red(` ✗ Error: ${e.message}`));
103
+ process.exit(1);
104
+ }
105
+ });
106
+
107
+ // Convenient top-level alias: git-worker clean [target] [-a]
108
+ program
109
+ .command("clean [target]")
110
+ .description("Shorthand to clean cached workspace(s)")
111
+ .option("-a, --all", "Clean all cached projects")
112
+ .option("-y, --yes", "Skip confirmation prompt")
113
+ .action(async (target, opts) => {
114
+ try {
115
+ await handleCacheClean(target, opts);
116
+ process.exit(0);
117
+ } catch (e: any) {
118
+ console.error(chalk.red(` ✗ Error: ${e.message}`));
119
+ process.exit(1);
120
+ }
121
+ });
122
+
123
+ program.parse();
@@ -0,0 +1,46 @@
1
+ import { AppConfig } from "./schema.js";
2
+
3
+ export const DEFAULT_CONFIG: AppConfig = {
4
+ git: {
5
+ remote: "origin",
6
+ fetchPrune: true,
7
+ cleanMode: "tracked-and-untracked",
8
+ pushPolicy: "prompt",
9
+ commitMessage: "chore: sync configuration",
10
+ },
11
+ workspace: {
12
+ root: ".git-worker",
13
+ lockTimeoutMs: 30000,
14
+ },
15
+ editor: {
16
+ name: "code",
17
+ editCommand: ["code", "--wait", "{file}"],
18
+ diffCommand: ["code", "--diff", "{before}", "{after}"],
19
+ },
20
+ template: {
21
+ placeholderOpen: "{",
22
+ placeholderClose: "}",
23
+ missingValuePolicy: "prompt-each-branch",
24
+ emptyValueIsPresent: true,
25
+ unmanagedKeyPolicy: "preserve",
26
+ removedTemplateKeyPolicy: "preserve",
27
+ },
28
+ review: {
29
+ autoApprove: false,
30
+ readOnlySnapshots: true,
31
+ },
32
+ execution: {
33
+ concurrency: 1,
34
+ stopOnBranchError: false,
35
+ },
36
+ report: {
37
+ writeJson: false,
38
+ writeMarkdown: false,
39
+ redactValues: true,
40
+ warnOnSecrets: true,
41
+ },
42
+ safety: {
43
+ rejectNoRemote: true,
44
+ saveOnPartialSuccess: true,
45
+ },
46
+ };
@@ -0,0 +1,70 @@
1
+ import { AppConfig, AppConfigSchema } from "./schema.js";
2
+ import { DEFAULT_CONFIG } from "./defaults.js";
3
+ import { CliInput } from "../core/types.js";
4
+ import { existsSync, readFileSync } from "fs";
5
+ import { join, resolve } from "path";
6
+
7
+ function loadProjectConfig(repoPath: string): Partial<AppConfig> {
8
+ const candidates = [
9
+ join(resolve(repoPath), "git-worker.config.json"),
10
+ join(resolve(repoPath), ".git-worker.config.json"),
11
+ ];
12
+ for (const candidate of candidates) {
13
+ if (existsSync(candidate)) {
14
+ try {
15
+ const raw = readFileSync(candidate, "utf-8");
16
+ return JSON.parse(raw) as Partial<AppConfig>;
17
+ } catch {
18
+ // ignore malformed
19
+ }
20
+ }
21
+ }
22
+ return {};
23
+ }
24
+
25
+ function deepMerge<T extends Record<string, unknown>>(base: T, override: Partial<T>): T {
26
+ const result = { ...base };
27
+ for (const key of Object.keys(override) as (keyof T)[]) {
28
+ const overrideVal = override[key];
29
+ if (overrideVal === undefined) continue;
30
+ if (
31
+ typeof overrideVal === "object" &&
32
+ overrideVal !== null &&
33
+ !Array.isArray(overrideVal) &&
34
+ typeof result[key] === "object" &&
35
+ result[key] !== null
36
+ ) {
37
+ result[key] = deepMerge(result[key] as Record<string, unknown>, overrideVal as Record<string, unknown>) as T[keyof T];
38
+ } else {
39
+ result[key] = overrideVal as T[keyof T];
40
+ }
41
+ }
42
+ return result;
43
+ }
44
+
45
+ export function loadConfig(cliInput: CliInput): AppConfig {
46
+ // Load project config from repo directory
47
+ const repoPath = cliInput.repo || process.cwd();
48
+ const projectConfig = loadProjectConfig(repoPath);
49
+
50
+ // Merge: defaults < project config < CLI overrides
51
+ const cliOverrides: Partial<AppConfig> = {
52
+ git: {
53
+ ...DEFAULT_CONFIG.git,
54
+ ...(cliInput.push ? { pushPolicy: cliInput.push } : {}),
55
+ ...(cliInput.commitMsg ? { commitMessage: cliInput.commitMsg } : {}),
56
+ ...(cliInput.remote ? { remote: cliInput.remote } : {}),
57
+ },
58
+ editor: {
59
+ ...DEFAULT_CONFIG.editor,
60
+ ...(cliInput.editor ? { name: cliInput.editor } : {}),
61
+ },
62
+ review: {
63
+ ...DEFAULT_CONFIG.review,
64
+ ...(cliInput.autoApprove !== undefined ? { autoApprove: cliInput.autoApprove } : {}),
65
+ },
66
+ };
67
+
68
+ const merged = deepMerge(deepMerge(DEFAULT_CONFIG as Record<string, unknown>, projectConfig as Record<string, unknown>) as AppConfig, cliOverrides);
69
+ return Object.freeze(AppConfigSchema.parse(merged)) as AppConfig;
70
+ }
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+
3
+ export const AppConfigSchema = z.object({
4
+ git: z.object({
5
+ remote: z.string().default("origin"),
6
+ fetchPrune: z.boolean().default(true),
7
+ cleanMode: z.enum(["tracked-and-untracked", "tracked-only"]).default("tracked-and-untracked"),
8
+ pushPolicy: z.enum(["prompt", "always", "never"]).default("prompt"),
9
+ commitMessage: z.string().default("chore: sync configuration"),
10
+ }),
11
+ workspace: z.object({
12
+ root: z.string().default(".git-worker"),
13
+ lockTimeoutMs: z.number().default(30000),
14
+ }),
15
+ editor: z.object({
16
+ name: z.string().default("code"),
17
+ editCommand: z.array(z.string()).default(["code", "--wait", "{file}"]),
18
+ diffCommand: z.array(z.string()).default(["code", "--diff", "{before}", "{after}"]),
19
+ }),
20
+ template: z.object({
21
+ placeholderOpen: z.string().default("{"),
22
+ placeholderClose: z.string().default("}"),
23
+ missingValuePolicy: z.enum(["prompt-each-branch"]).default("prompt-each-branch"),
24
+ emptyValueIsPresent: z.boolean().default(true),
25
+ unmanagedKeyPolicy: z.enum(["preserve"]).default("preserve"),
26
+ removedTemplateKeyPolicy: z.enum(["preserve"]).default("preserve"),
27
+ }),
28
+ review: z.object({
29
+ autoApprove: z.boolean().default(false),
30
+ readOnlySnapshots: z.boolean().default(true),
31
+ }),
32
+ execution: z.object({
33
+ concurrency: z.number().default(1),
34
+ stopOnBranchError: z.boolean().default(false),
35
+ }),
36
+ report: z.object({
37
+ writeJson: z.boolean().default(false),
38
+ writeMarkdown: z.boolean().default(false),
39
+ redactValues: z.boolean().default(true),
40
+ warnOnSecrets: z.boolean().default(true),
41
+ }),
42
+ safety: z.object({
43
+ rejectNoRemote: z.boolean().default(true),
44
+ saveOnPartialSuccess: z.boolean().default(true),
45
+ }),
46
+ });
47
+
48
+ export type AppConfig = z.infer<typeof AppConfigSchema>;
@@ -0,0 +1,45 @@
1
+ import { AppError } from "./types.js";
2
+
3
+ export function configError(msg: string, details?: unknown): AppError {
4
+ return new AppError("CONFIG", msg, details);
5
+ }
6
+
7
+ export function gitError(msg: string, details?: unknown): AppError {
8
+ return new AppError("GIT", msg, details);
9
+ }
10
+
11
+ export function editorError(msg: string, details?: unknown): AppError {
12
+ return new AppError("EDITOR", msg, details);
13
+ }
14
+
15
+ export function templateError(msg: string, details?: unknown): AppError {
16
+ return new AppError("TEMPLATE", msg, details);
17
+ }
18
+
19
+ export function eligibilityError(msg: string, details?: unknown): AppError {
20
+ return new AppError("ELIGIBILITY", msg, details);
21
+ }
22
+
23
+ export function inputCancelledError(msg: string, details?: unknown): AppError {
24
+ return new AppError("INPUT_CANCELLED", msg, details);
25
+ }
26
+
27
+ export function stalePlanError(msg: string, details?: unknown): AppError {
28
+ return new AppError("STALE_PLAN", msg, details);
29
+ }
30
+
31
+ export function applyError(msg: string, details?: unknown): AppError {
32
+ return new AppError("APPLY", msg, details);
33
+ }
34
+
35
+ export function pushError(msg: string, details?: unknown): AppError {
36
+ return new AppError("PUSH", msg, details);
37
+ }
38
+
39
+ export function workspaceError(msg: string, details?: unknown): AppError {
40
+ return new AppError("WORKSPACE", msg, details);
41
+ }
42
+
43
+ export function validationError(msg: string, details?: unknown): AppError {
44
+ return new AppError("VALIDATION", msg, details);
45
+ }
@@ -0,0 +1,17 @@
1
+ import { Result, AppError, ok } from "./types.js";
2
+
3
+ export type Stage<C, D> = (context: C, deps: D) => Promise<Result<C, AppError>>;
4
+
5
+ export function pipeline<C, D>(...stages: Stage<C, D>[]): Stage<C, D> {
6
+ return async (initial: C, deps: D): Promise<Result<C, AppError>> => {
7
+ let current = initial;
8
+ for (const stage of stages) {
9
+ const result = await stage(current, deps);
10
+ if (!result.ok) {
11
+ return result;
12
+ }
13
+ current = result.value;
14
+ }
15
+ return ok(current);
16
+ };
17
+ }
@@ -0,0 +1,188 @@
1
+ export type Result<T, E = AppError> =
2
+ | { ok: true; value: T }
3
+ | { ok: false; error: E };
4
+
5
+ export type AppErrorCategory =
6
+ | "CONFIG"
7
+ | "GIT"
8
+ | "EDITOR"
9
+ | "TEMPLATE"
10
+ | "ELIGIBILITY"
11
+ | "INPUT_CANCELLED"
12
+ | "STALE_PLAN"
13
+ | "APPLY"
14
+ | "PUSH"
15
+ | "WORKSPACE"
16
+ | "VALIDATION";
17
+
18
+ export class AppError extends Error {
19
+ category: AppErrorCategory;
20
+ details?: unknown;
21
+
22
+ constructor(category: AppErrorCategory, message: string, details?: unknown) {
23
+ super(message);
24
+ this.name = "AppError";
25
+ this.category = category;
26
+ this.details = details;
27
+ }
28
+ }
29
+
30
+ export function ok<T>(value: T): Result<T> {
31
+ return { ok: true, value };
32
+ }
33
+
34
+ export function err<E extends AppError>(error: E): Result<never, E> {
35
+ return { ok: false, error };
36
+ }
37
+
38
+ export type BranchRef = {
39
+ name: string;
40
+ remote?: string;
41
+ sha: string;
42
+ isRemote: boolean;
43
+ };
44
+
45
+ export type TargetFile = {
46
+ path: string;
47
+ };
48
+
49
+ export type TemplateEngineType = "env-reconcile" | "source-of-truth";
50
+
51
+ export type TargetTemplate = {
52
+ path: string;
53
+ engine: TemplateEngineType;
54
+ raw: string;
55
+ parsedEnv?: TemplateDocument;
56
+ variables?: Array<{ name: string; fallback?: string; raw: string }>;
57
+ isNewFile?: boolean;
58
+ };
59
+
60
+ export type ManagedProject = {
61
+ id: string;
62
+ sourceRepoPath: string;
63
+ remoteUrl?: string;
64
+ managedRepoPath: string;
65
+ stagingDir: string;
66
+ stateDir: string;
67
+ reportsDir: string;
68
+ markerPath: string;
69
+ isCached?: boolean;
70
+ };
71
+
72
+ export type TemplateDocument = {
73
+ raw: string;
74
+ assignments: TemplateAssignment[];
75
+ placeholders: string[];
76
+ };
77
+
78
+ export type TemplateAssignment = {
79
+ key: string;
80
+ value: string;
81
+ isStatic: boolean;
82
+ isPlaceholder: boolean;
83
+ placeholderName?: string;
84
+ originalLine: string;
85
+ };
86
+
87
+ export type EnvEntry = {
88
+ key: string;
89
+ value: string;
90
+ lineIndex: number;
91
+ originalLine: string;
92
+ hasExport: boolean;
93
+ };
94
+
95
+ export type BranchContent = {
96
+ branch: BranchRef;
97
+ exists: boolean;
98
+ content?: string;
99
+ error?: string;
100
+ };
101
+
102
+ export type MissingValueRequest = {
103
+ branch: BranchRef;
104
+ key: string;
105
+ };
106
+
107
+ export type BranchPlan = {
108
+ branch: BranchRef;
109
+ baseCommitSha: string;
110
+ path: string;
111
+ beforeContent: string;
112
+ beforeContentHash: string;
113
+ afterContent: string;
114
+ afterContentHash: string;
115
+ diff: string;
116
+ planHash: string;
117
+ status: "changed" | "unchanged" | "error";
118
+ reason?: string;
119
+ addedKeys: string[];
120
+ updatedKeys: string[];
121
+ missingVars?: string[];
122
+ isNewFile?: boolean;
123
+ };
124
+
125
+ export type Approval = {
126
+ branchName: string;
127
+ approved: boolean;
128
+ planHash: string;
129
+ };
130
+
131
+ export type BranchResult = {
132
+ branch: BranchRef;
133
+ status: "skipped" | "rejected" | "unchanged" | "committed" | "failed";
134
+ commitSha?: string;
135
+ reason?: string;
136
+ };
137
+
138
+ export type PushResult = {
139
+ branch: BranchRef;
140
+ status: "pushed" | "skipped" | "failed";
141
+ reason?: string;
142
+ };
143
+
144
+ export type CliInput = {
145
+ repo?: string;
146
+ cached?: boolean;
147
+ branch?: string;
148
+ invertBranch?: boolean;
149
+ file?: string;
150
+ editor?: string;
151
+ commitMsg?: string;
152
+ push?: "prompt" | "always" | "never";
153
+ remote?: string;
154
+ autoApprove?: boolean;
155
+ ci?: boolean;
156
+ };
157
+
158
+ export type AppServices = {
159
+ git: import("../services/git/reader.js").GitReader;
160
+ gitOps: import("../services/git/ops.js").GitOps;
161
+ workspace: import("../services/workspace/manager.js").WorkspaceManager;
162
+ template: import("../services/template/transform.js").TemplateService;
163
+ state: import("../services/state/index.js").StateService;
164
+ editor: import("../services/editor/index.js").EditorService;
165
+ review: import("../services/review/index.js").ReviewService;
166
+ };
167
+
168
+ export type AppDeps = {
169
+ config: import("../config/schema.js").AppConfig;
170
+ services: AppServices;
171
+ };
172
+
173
+ export type RunContext = {
174
+ runId: string;
175
+ input: CliInput;
176
+ project?: ManagedProject;
177
+ selectedBranches?: BranchRef[];
178
+ target?: TargetFile;
179
+ targets?: TargetFile[];
180
+ eligible?: BranchRef[];
181
+ template?: TemplateDocument;
182
+ activeTemplate?: TargetTemplate;
183
+ templates?: TargetTemplate[];
184
+ plans?: BranchPlan[];
185
+ approvals?: Approval[];
186
+ results?: BranchResult[];
187
+ pushResults?: PushResult[];
188
+ };
@@ -0,0 +1,85 @@
1
+ import { Result, ok, err } from "../../core/types.js";
2
+ import { editorError, inputCancelledError } from "../../core/errors.js";
3
+ import { spawn } from "child_process";
4
+ import { AppConfig } from "../../config/schema.js";
5
+ import { existsSync } from "fs";
6
+ import { which } from "../../utils/which.js";
7
+
8
+ export type EditorService = {
9
+ openForEdit(templatePath: string, config: AppConfig): Promise<Result<void>>;
10
+ openForDiff(beforePath: string, afterPath: string, config: AppConfig): Promise<Result<void>>;
11
+ };
12
+
13
+ /**
14
+ * Substitute {file}, {before}, {after} placeholders in a command template.
15
+ */
16
+ function buildCommand(
17
+ template: string[],
18
+ vars: Record<string, string>
19
+ ): string[] {
20
+ return template.map((part) => {
21
+ let result = part;
22
+ for (const [k, v] of Object.entries(vars)) {
23
+ result = result.replace(`{${k}}`, v);
24
+ }
25
+ return result;
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Spawn a command and wait for it to exit.
31
+ * Returns an error if the process exits with a non-zero code.
32
+ */
33
+ async function runCommand(
34
+ cmd: string[],
35
+ description: string
36
+ ): Promise<Result<void>> {
37
+ return new Promise((resolve) => {
38
+ const [bin, ...args] = cmd;
39
+ const proc = spawn(bin, args, { stdio: "inherit" });
40
+
41
+ proc.on("error", (e: any) => {
42
+ if (e.code === "ENOENT") {
43
+ resolve(err(editorError(`Editor '${bin}' not found. Install it or set a different editor via --editor or git-worker.config.json.`)));
44
+ } else {
45
+ resolve(err(editorError(`${description} failed: ${e.message}`)));
46
+ }
47
+ });
48
+
49
+ proc.on("close", (code) => {
50
+ if (code === 0) {
51
+ resolve(ok(undefined));
52
+ } else {
53
+ // Some editors (like VS Code) return non-zero on cancel
54
+ resolve(ok(undefined));
55
+ }
56
+ });
57
+ });
58
+ }
59
+
60
+ export async function openForEdit(
61
+ templatePath: string,
62
+ config: AppConfig
63
+ ): Promise<Result<void>> {
64
+ const cmd = buildCommand(config.editor.editCommand, { file: templatePath });
65
+ return runCommand(cmd, "Editor");
66
+ }
67
+
68
+ export async function openForDiff(
69
+ beforePath: string,
70
+ afterPath: string,
71
+ config: AppConfig
72
+ ): Promise<Result<void>> {
73
+ const cmd = buildCommand(config.editor.diffCommand, {
74
+ before: beforePath,
75
+ after: afterPath,
76
+ });
77
+ return runCommand(cmd, "Diff viewer");
78
+ }
79
+
80
+ export function createEditorService(): EditorService {
81
+ return {
82
+ openForEdit,
83
+ openForDiff,
84
+ };
85
+ }