@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,169 @@
1
+ import { TemplateDocument, TemplateAssignment, Result, EnvEntry, ok, err } from "../../core/types.js";
2
+ import { templateError } from "../../core/errors.js";
3
+
4
+ /**
5
+ * Parse a .env template file into structured assignments.
6
+ * Template uses {KEY} for dynamic placeholders, KEY=value for static.
7
+ */
8
+ export function parseEnvTemplate(raw: string): Result<TemplateDocument> {
9
+ const lines = raw.replace(/\r\n/g, "\n").split("\n");
10
+ const assignments: TemplateAssignment[] = [];
11
+ const placeholders = new Set<string>();
12
+ const seenKeys = new Map<string, number>();
13
+
14
+ for (let i = 0; i < lines.length; i++) {
15
+ const line = lines[i];
16
+ const trimmed = line.trim();
17
+
18
+ // Preserve blank lines and full-line comments — skip for assignment parsing
19
+ if (trimmed === "" || trimmed.startsWith("#")) {
20
+ continue;
21
+ }
22
+
23
+ // Handle `export KEY=...`
24
+ let workingLine = trimmed;
25
+ if (workingLine.startsWith("export ")) {
26
+ workingLine = workingLine.slice(7).trimStart();
27
+ }
28
+
29
+ // Find = sign
30
+ const eqIndex = workingLine.indexOf("=");
31
+ if (eqIndex === -1) {
32
+ return err(templateError(`Line ${i + 1}: missing '=' in assignment`));
33
+ }
34
+
35
+ const key = workingLine.slice(0, eqIndex).trimEnd();
36
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
37
+ return err(templateError(`Line ${i + 1}: invalid key name '${key}'`));
38
+ }
39
+
40
+ if (seenKeys.has(key)) {
41
+ return err(templateError(`Duplicate key '${key}' at line ${i + 1} (first seen at line ${seenKeys.get(key)! + 1})`));
42
+ }
43
+ seenKeys.set(key, i);
44
+
45
+ // Value: everything after the first '=', no trimming (preserve quoting)
46
+ const value = workingLine.slice(eqIndex + 1);
47
+
48
+ // Check for placeholder {KEY} — must be the entire value
49
+ const placeholderMatch = value.match(/^\{([A-Za-z_][A-Za-z0-9_]*)\}$/);
50
+ if (placeholderMatch) {
51
+ const placeholderName = placeholderMatch[1];
52
+ placeholders.add(placeholderName);
53
+ assignments.push({
54
+ key,
55
+ value,
56
+ isStatic: false,
57
+ isPlaceholder: true,
58
+ placeholderName,
59
+ originalLine: line,
60
+ });
61
+ } else {
62
+ assignments.push({
63
+ key,
64
+ value,
65
+ isStatic: true,
66
+ isPlaceholder: false,
67
+ originalLine: line,
68
+ });
69
+ }
70
+ }
71
+
72
+ return ok({
73
+ raw,
74
+ assignments,
75
+ placeholders: Array.from(placeholders),
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Parse a .env file into a map of key → EnvEntry (preserving export, quoting, lineIndex).
81
+ * Returns an error if duplicate keys are found.
82
+ * Lines that cannot be parsed as assignments are tracked but not included in the map.
83
+ */
84
+ export function parseEnvFile(
85
+ content: string
86
+ ): Result<{ map: Map<string, EnvEntry>; lines: string[] }> {
87
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
88
+ const map = new Map<string, EnvEntry>();
89
+ const seen = new Map<string, number>();
90
+
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const line = lines[i];
93
+ const trimmed = line.trim();
94
+
95
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
96
+
97
+ let workingLine = trimmed;
98
+ let hasExport = false;
99
+ if (workingLine.startsWith("export ")) {
100
+ hasExport = true;
101
+ workingLine = workingLine.slice(7).trimStart();
102
+ }
103
+
104
+ const eqIndex = workingLine.indexOf("=");
105
+ if (eqIndex === -1) {
106
+ // Not a valid assignment — preserve line as-is, do not fail
107
+ continue;
108
+ }
109
+
110
+ const key = workingLine.slice(0, eqIndex).trimEnd();
111
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
112
+
113
+ if (seen.has(key)) {
114
+ return err(templateError(`Duplicate key '${key}' at line ${i + 1} (first seen at line ${seen.get(key)! + 1}) in branch file`));
115
+ }
116
+ seen.set(key, i);
117
+
118
+ // Value: everything after '=', preserve as-is (no trim)
119
+ const value = workingLine.slice(eqIndex + 1);
120
+
121
+ map.set(key, { key, value, lineIndex: i, originalLine: line, hasExport });
122
+ }
123
+
124
+ return ok({ map, lines });
125
+ }
126
+
127
+ /**
128
+ * Synthesize a first-run template draft from common keys across branches.
129
+ * Uniform values → static assignment. Varying values → dynamic placeholder.
130
+ */
131
+ export function synthesizeTemplate(
132
+ branchContents: Array<{ branchName: string; content: string }>
133
+ ): string {
134
+ if (branchContents.length === 0) {
135
+ return "# Git Worker Template\n# Use KEY=value for static values enforced on all branches\n# Use KEY={KEY} to read existing value from each branch\n";
136
+ }
137
+
138
+ // Collect all keys and their values per branch
139
+ const keyValues = new Map<string, Map<string, string>>(); // key → branchName → value
140
+ for (const { branchName, content } of branchContents) {
141
+ const parsed = parseEnvFile(content);
142
+ if (!parsed.ok) continue;
143
+ for (const [key, entry] of parsed.value.map) {
144
+ if (!keyValues.has(key)) keyValues.set(key, new Map());
145
+ keyValues.get(key)!.set(branchName, entry.value);
146
+ }
147
+ }
148
+
149
+ // Build template lines
150
+ const lines: string[] = [
151
+ "# Git Worker Template",
152
+ "# Static: KEY=value — enforced on every branch",
153
+ "# Dynamic: KEY={KEY} — reads existing value from each branch",
154
+ "",
155
+ ];
156
+
157
+ for (const [key, branchMap] of keyValues) {
158
+ const values = Array.from(branchMap.values());
159
+ const allSame = values.every((v) => v === values[0]);
160
+ if (allSame) {
161
+ lines.push(`${key}=${values[0]}`);
162
+ } else {
163
+ lines.push(`# Varies by branch — using dynamic placeholder`);
164
+ lines.push(`${key}={${key}}`);
165
+ }
166
+ }
167
+
168
+ return lines.join("\n") + "\n";
169
+ }
@@ -0,0 +1,109 @@
1
+ import { Result, ok } from "../../core/types.js";
2
+
3
+ export type TemplateVariable = {
4
+ name: string;
5
+ fallback?: string;
6
+ raw: string;
7
+ };
8
+
9
+ export type BranchContext = {
10
+ branchName: string;
11
+ repoName?: string;
12
+ commitSha?: string;
13
+ };
14
+
15
+ const VARIABLE_REGEX = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
16
+
17
+ /**
18
+ * Convert a branch name into a DNS/Docker-safe slug.
19
+ * e.g. "uat/payments-service" -> "uat-payments-service"
20
+ */
21
+ export function slugifyBranchName(branchName: string): string {
22
+ return branchName
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-+|-+$/g, "");
26
+ }
27
+
28
+ /**
29
+ * Extract all ${VAR} and ${VAR:-default} placeholders from template text.
30
+ */
31
+ export function extractTemplateVariables(templateText: string): TemplateVariable[] {
32
+ const vars: TemplateVariable[] = [];
33
+ const seen = new Set<string>();
34
+
35
+ VARIABLE_REGEX.lastIndex = 0;
36
+ let match: RegExpExecArray | null;
37
+
38
+ while ((match = VARIABLE_REGEX.exec(templateText)) !== null) {
39
+ const raw = match[0];
40
+ const name = match[1];
41
+ const fallback = match[2];
42
+
43
+ const key = `${name}::${fallback ?? ""}`;
44
+ if (!seen.has(key)) {
45
+ seen.add(key);
46
+ vars.push({ name, fallback, raw });
47
+ }
48
+ }
49
+
50
+ return vars;
51
+ }
52
+
53
+ /**
54
+ * Interpolate a template with variables from:
55
+ * 1. Built-in branch metadata (BRANCH_NAME, BRANCH_SLUG, REPO_NAME, COMMIT_SHA, SHORT_SHA)
56
+ * 2. Custom variables resolved from the branch's .env file
57
+ * 3. Fallback defaults (${VAR:-default})
58
+ */
59
+ export function interpolateTemplate(
60
+ templateText: string,
61
+ envMap: Map<string, string>,
62
+ context: BranchContext
63
+ ): Result<{ content: string; missingVars: string[] }> {
64
+ const branchName = context.branchName;
65
+ const branchSlug = slugifyBranchName(branchName);
66
+ const repoName = context.repoName ?? "";
67
+ const commitSha = context.commitSha ?? "";
68
+ const shortSha = commitSha.slice(0, 7);
69
+
70
+ const builtins = new Map<string, string>([
71
+ ["BRANCH_NAME", branchName],
72
+ ["BRANCH_SLUG", branchSlug],
73
+ ["REPO_NAME", repoName],
74
+ ["COMMIT_SHA", commitSha],
75
+ ["SHORT_SHA", shortSha],
76
+ ]);
77
+
78
+ const missingSet = new Set<string>();
79
+
80
+ VARIABLE_REGEX.lastIndex = 0;
81
+ const content = templateText.replace(
82
+ VARIABLE_REGEX,
83
+ (fullMatch, varName: string, fallback?: string) => {
84
+ // 1. Built-in variables
85
+ if (builtins.has(varName)) {
86
+ return builtins.get(varName)!;
87
+ }
88
+
89
+ // 2. Variables from branch .env
90
+ if (envMap.has(varName) && envMap.get(varName) !== undefined) {
91
+ return envMap.get(varName)!;
92
+ }
93
+
94
+ // 3. Fallback default (${VAR:-default})
95
+ if (fallback !== undefined) {
96
+ return fallback;
97
+ }
98
+
99
+ // 4. Missing required variable without fallback
100
+ missingSet.add(varName);
101
+ return fullMatch;
102
+ }
103
+ );
104
+
105
+ return ok({
106
+ content,
107
+ missingVars: Array.from(missingSet),
108
+ });
109
+ }
@@ -0,0 +1,97 @@
1
+ import { TemplateDocument, Result, ok, err } from "../../core/types.js";
2
+ import { templateError } from "../../core/errors.js";
3
+ import { parseEnvFile } from "./env.js";
4
+
5
+ export type TransformResult = {
6
+ content: string;
7
+ changed: boolean;
8
+ addedKeys: string[];
9
+ updatedKeys: string[];
10
+ };
11
+
12
+ export type TemplateService = {
13
+ transform(
14
+ branchContent: string,
15
+ template: TemplateDocument,
16
+ branchValues: Map<string, string>
17
+ ): Result<TransformResult>;
18
+ };
19
+
20
+ /**
21
+ * Apply the template to a branch's existing .env content.
22
+ *
23
+ * Rules:
24
+ * - Static key: update the value in-place (preserve `export` prefix if present)
25
+ * - Dynamic key (placeholder): if key exists in branch, leave it as-is; if missing, use branchValues
26
+ * - Keys not mentioned in template: preserved untouched
27
+ * - Preserve key ordering, comments, blank lines
28
+ * - Appended keys are added at end
29
+ */
30
+ export function transformEnv(
31
+ branchContent: string,
32
+ template: TemplateDocument,
33
+ branchValues: Map<string, string>
34
+ ): Result<TransformResult> {
35
+ const parseResult = parseEnvFile(branchContent);
36
+ if (!parseResult.ok) return parseResult;
37
+
38
+ const { map: existingMap, lines } = parseResult.value;
39
+ const resultLines = [...lines];
40
+ const updatedKeys: string[] = [];
41
+ const addedKeys: string[] = [];
42
+
43
+ for (const assignment of template.assignments) {
44
+ if (assignment.isPlaceholder && assignment.placeholderName) {
45
+ // Dynamic: read from branch
46
+ if (existingMap.has(assignment.key)) {
47
+ // Key exists — preserve exactly as-is, do nothing
48
+ continue;
49
+ } else {
50
+ // Key missing — append with provided value
51
+ const val = branchValues.get(assignment.key);
52
+ if (val === undefined) {
53
+ return err(templateError(`Missing value for '${assignment.key}' — not found in branch and no value provided`));
54
+ }
55
+ resultLines.push(`${assignment.key}=${val}`);
56
+ addedKeys.push(assignment.key);
57
+ }
58
+ } else {
59
+ // Static: enforce the template value
60
+ if (existingMap.has(assignment.key)) {
61
+ const existing = existingMap.get(assignment.key)!;
62
+ // Reconstruct line, preserving `export` keyword if it was there
63
+ const prefix = existing.hasExport ? "export " : "";
64
+ const newLine = `${prefix}${assignment.key}=${assignment.value}`;
65
+ if (resultLines[existing.lineIndex] !== newLine) {
66
+ resultLines[existing.lineIndex] = newLine;
67
+ updatedKeys.push(assignment.key);
68
+ }
69
+ } else {
70
+ resultLines.push(`${assignment.key}=${assignment.value}`);
71
+ addedKeys.push(assignment.key);
72
+ }
73
+ }
74
+ }
75
+
76
+ // Reconstruct preserving trailing newline convention
77
+ const hadTrailingNewline = branchContent.endsWith("\n");
78
+ let newContent = resultLines.join("\n");
79
+ if (hadTrailingNewline && !newContent.endsWith("\n")) {
80
+ newContent += "\n";
81
+ }
82
+
83
+ const changed = newContent !== branchContent;
84
+
85
+ return ok({
86
+ content: newContent,
87
+ changed,
88
+ addedKeys,
89
+ updatedKeys,
90
+ });
91
+ }
92
+
93
+ export function createTemplateService(): TemplateService {
94
+ return {
95
+ transform: transformEnv,
96
+ };
97
+ }
@@ -0,0 +1,75 @@
1
+ import { Result, ok, err } from "../../core/types.js";
2
+ import { templateError } from "../../core/errors.js";
3
+ import { parseEnvTemplate } from "./env.js";
4
+ import yaml from "yaml";
5
+
6
+ function checkUnclosedDelimiters(content: string): Result<void> {
7
+ let idx = 0;
8
+ while ((idx = content.indexOf("${", idx)) !== -1) {
9
+ const closeIdx = content.indexOf("}", idx);
10
+ const newlineIdx = content.indexOf("\n", idx);
11
+ if (closeIdx === -1 || (newlineIdx !== -1 && closeIdx > newlineIdx)) {
12
+ const lineNum = content.slice(0, idx).split("\n").length;
13
+ return err(templateError(`Unclosed variable delimiter '\${' at line ${lineNum}`));
14
+ }
15
+ idx = closeIdx + 1;
16
+ }
17
+ return ok(undefined);
18
+ }
19
+
20
+ function validateJson(content: string): Result<void> {
21
+ // Replace ${...} placeholders with a dummy string token to check JSON syntax
22
+ const sanitized = content.replace(/\$\{([^}]*)\}/g, "__GW_PLACEHOLDER__");
23
+ try {
24
+ JSON.parse(sanitized);
25
+ return ok(undefined);
26
+ } catch (e: any) {
27
+ return err(templateError(`JSON syntax error: ${e.message}`));
28
+ }
29
+ }
30
+
31
+ function validateYaml(content: string): Result<void> {
32
+ // Replace ${...} placeholders with safe string to check YAML syntax
33
+ const sanitized = content.replace(/\$\{([^}]*)\}/g, "__GW_PLACEHOLDER__");
34
+ try {
35
+ yaml.parse(sanitized);
36
+ return ok(undefined);
37
+ } catch (e: any) {
38
+ return err(templateError(`YAML syntax error: ${e.message}`));
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Validate the syntax of a template based on its file format and delimiters.
44
+ */
45
+ export function validateTemplateSyntax(filePath: string, content: string): Result<void> {
46
+ // Check unclosed delimiters first for all template formats
47
+ const delimiterCheck = checkUnclosedDelimiters(content);
48
+ if (!delimiterCheck.ok) {
49
+ return delimiterCheck;
50
+ }
51
+
52
+ const fileName = filePath.split("/").pop() ?? filePath;
53
+
54
+ // 1. .env files
55
+ if (fileName === ".env" || fileName.startsWith(".env.")) {
56
+ const envParse = parseEnvTemplate(content);
57
+ if (!envParse.ok) {
58
+ return envParse;
59
+ }
60
+ return ok(undefined);
61
+ }
62
+
63
+ // 2. JSON files
64
+ if (fileName.endsWith(".json")) {
65
+ return validateJson(content);
66
+ }
67
+
68
+ // 3. YAML files
69
+ if (fileName.endsWith(".yaml") || fileName.endsWith(".yml")) {
70
+ return validateYaml(content);
71
+ }
72
+
73
+ // Other text/Dockerfile: valid syntax if delimiters are closed
74
+ return ok(undefined);
75
+ }
@@ -0,0 +1,144 @@
1
+ import { existsSync } from "fs";
2
+ import { readdir, readFile, rm, mkdir } from "fs/promises";
3
+ import { join } from "path";
4
+ import { Result, ok, err } from "../../core/types.js";
5
+ import { workspaceError } from "../../core/errors.js";
6
+ import { getWorkspaceRoot, extractRepoName } from "./manager.js";
7
+ import { AppConfig } from "../../config/schema.js";
8
+
9
+ export { extractRepoName };
10
+
11
+ export type CachedProjectInfo = {
12
+ id: string;
13
+ name: string;
14
+ sourceRepoPath: string;
15
+ remoteUrl?: string;
16
+ createdAt: string;
17
+ gitWorkerVersion?: string;
18
+ projectDir: string;
19
+ hasRepo: boolean;
20
+ };
21
+
22
+ /**
23
+ * List all cached projects in the git-worker workspace root (~/.git-worker/projects)
24
+ */
25
+ export async function listCachedProjects(config?: AppConfig): Promise<CachedProjectInfo[]> {
26
+ const root = getWorkspaceRoot(config);
27
+ const projectsDir = join(root, "projects");
28
+
29
+ if (!existsSync(projectsDir)) {
30
+ return [];
31
+ }
32
+
33
+ try {
34
+ const entries = await readdir(projectsDir, { withFileTypes: true });
35
+ const projects: CachedProjectInfo[] = [];
36
+
37
+ for (const entry of entries) {
38
+ if (!entry.isDirectory()) continue;
39
+ const projectDir = join(projectsDir, entry.name);
40
+ const markerPath = join(projectDir, ".git-worker-marker");
41
+
42
+ if (existsSync(markerPath)) {
43
+ try {
44
+ const raw = await readFile(markerPath, "utf-8");
45
+ const data = JSON.parse(raw);
46
+ const hasRepo = existsSync(join(projectDir, "repo"));
47
+ const name =
48
+ data.name ||
49
+ extractRepoName(data.remoteUrl || data.sourceRepoPath, entry.name);
50
+
51
+ projects.push({
52
+ id: data.id || entry.name,
53
+ name,
54
+ sourceRepoPath: data.sourceRepoPath || "",
55
+ remoteUrl: data.remoteUrl || undefined,
56
+ createdAt: data.createdAt || "",
57
+ gitWorkerVersion: data.gitWorkerVersion,
58
+ projectDir,
59
+ hasRepo,
60
+ });
61
+ } catch {
62
+ // malformed marker, still list with id
63
+ projects.push({
64
+ id: entry.name,
65
+ name: entry.name,
66
+ sourceRepoPath: "",
67
+ projectDir,
68
+ createdAt: "",
69
+ hasRepo: existsSync(join(projectDir, "repo")),
70
+ });
71
+ }
72
+ }
73
+ }
74
+
75
+ return projects.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Clear a specific project cache by project ID, name, remote URL, or path.
83
+ */
84
+ export async function clearProjectCache(
85
+ target: string,
86
+ config?: AppConfig
87
+ ): Promise<Result<{ removed: string; projectDir: string }>> {
88
+ const projects = await listCachedProjects(config);
89
+
90
+ const trimmed = target.trim();
91
+ const match = projects.find(
92
+ (p) =>
93
+ p.id === trimmed ||
94
+ p.id.startsWith(trimmed) ||
95
+ p.name.toLowerCase() === trimmed.toLowerCase() ||
96
+ (p.remoteUrl && (p.remoteUrl === trimmed || p.remoteUrl.includes(trimmed))) ||
97
+ p.sourceRepoPath === trimmed ||
98
+ p.sourceRepoPath.includes(trimmed)
99
+ );
100
+
101
+ if (!match) {
102
+ return err(
103
+ workspaceError(
104
+ `No cached project found matching '${target}'. Run 'git-worker cache list' to view all cached projects.`
105
+ )
106
+ );
107
+ }
108
+
109
+ try {
110
+ await rm(match.projectDir, { recursive: true, force: true });
111
+ return ok({
112
+ removed: `${match.name} (${match.remoteUrl || match.sourceRepoPath || match.id})`,
113
+ projectDir: match.projectDir,
114
+ });
115
+ } catch (e: any) {
116
+ return err(workspaceError(`Failed to remove project cache: ${e.message}`, e));
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Clear all cached projects and workspaces in ~/.git-worker/projects.
122
+ */
123
+ export async function clearAllCache(
124
+ config?: AppConfig
125
+ ): Promise<Result<{ count: number; root: string }>> {
126
+ const root = getWorkspaceRoot(config);
127
+ const projectsDir = join(root, "projects");
128
+
129
+ if (!existsSync(projectsDir)) {
130
+ return ok({ count: 0, root: projectsDir });
131
+ }
132
+
133
+ try {
134
+ const projects = await listCachedProjects(config);
135
+ const count = projects.length;
136
+
137
+ await rm(projectsDir, { recursive: true, force: true });
138
+ await mkdir(projectsDir, { recursive: true });
139
+
140
+ return ok({ count, root: projectsDir });
141
+ } catch (e: any) {
142
+ return err(workspaceError(`Failed to clear cache: ${e.message}`, e));
143
+ }
144
+ }