@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,170 @@
1
+ import { simpleGit, SimpleGit } from "simple-git";
2
+ import { Result, ok, err } from "../../core/types.js";
3
+ import { gitError } from "../../core/errors.js";
4
+ import { writeFile, mkdir, rename, mkdtemp } from "fs/promises";
5
+ import { dirname, join } from "path";
6
+ import { tmpdir } from "os";
7
+
8
+ export type GitOps = {
9
+ ensureRepo(repoPath: string): Promise<Result<void>>;
10
+ checkoutBranch(repoPath: string, branchName: string): Promise<Result<void>>;
11
+ resetHard(repoPath: string): Promise<Result<void>>;
12
+ clean(repoPath: string, mode?: "tracked-and-untracked" | "tracked-only"): Promise<Result<void>>;
13
+ writeAndStage(repoPath: string, filePath: string, content: string): Promise<Result<void>>;
14
+ getStagedFiles(repoPath: string): Promise<Result<string[]>>;
15
+ commit(repoPath: string, message: string): Promise<Result<string>>;
16
+ push(repoPath: string, remote: string, branchName: string): Promise<Result<void>>;
17
+ getBranchSha(repoPath: string, branchName: string): Promise<Result<string>>;
18
+ };
19
+
20
+ export async function ensureRepo(repoPath: string): Promise<Result<void>> {
21
+ try {
22
+ const git: SimpleGit = simpleGit(repoPath);
23
+ const isRepo = await git.checkIsRepo();
24
+ if (!isRepo) return err(gitError("Not a git repository"));
25
+ return ok(undefined);
26
+ } catch (e: any) {
27
+ return err(gitError("Failed to check repository", e));
28
+ }
29
+ }
30
+
31
+ export async function checkoutBranch(
32
+ repoPath: string,
33
+ branchName: string
34
+ ): Promise<Result<void>> {
35
+ try {
36
+ const git: SimpleGit = simpleGit(repoPath);
37
+ // Align local branch to origin/branchName if remote branch exists
38
+ try {
39
+ await git.checkout(["-B", branchName, `origin/${branchName}`]);
40
+ } catch {
41
+ // Fallback for local-only branch
42
+ await git.checkout(branchName);
43
+ }
44
+ return ok(undefined);
45
+ } catch (e: any) {
46
+ return err(gitError(`Failed to checkout '${branchName}'`, e));
47
+ }
48
+ }
49
+
50
+ export async function resetHard(repoPath: string): Promise<Result<void>> {
51
+ try {
52
+ const git: SimpleGit = simpleGit(repoPath);
53
+ await git.reset(["--hard"]);
54
+ return ok(undefined);
55
+ } catch (e: any) {
56
+ return err(gitError("Failed to reset --hard", e));
57
+ }
58
+ }
59
+
60
+ export async function clean(
61
+ repoPath: string,
62
+ mode: "tracked-and-untracked" | "tracked-only" = "tracked-and-untracked"
63
+ ): Promise<Result<void>> {
64
+ try {
65
+ const git: SimpleGit = simpleGit(repoPath);
66
+ if (mode === "tracked-and-untracked") {
67
+ await git.clean("f", ["-d"]);
68
+ }
69
+ // tracked-only: git reset --hard already handles it
70
+ return ok(undefined);
71
+ } catch (e: any) {
72
+ return err(gitError("Failed to clean working tree", e));
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Atomically write a file (via temp file + rename) and stage it.
78
+ * Only stages the specific path — no glob or wildcard.
79
+ */
80
+ export async function writeAndStage(
81
+ repoPath: string,
82
+ filePath: string,
83
+ content: string
84
+ ): Promise<Result<void>> {
85
+ try {
86
+ const fullPath = join(repoPath, filePath);
87
+ await mkdir(dirname(fullPath), { recursive: true });
88
+
89
+ // Atomic write: write to temp, then rename
90
+ const tmp = fullPath + ".git-worker-tmp";
91
+ await writeFile(tmp, content, "utf-8");
92
+ await rename(tmp, fullPath);
93
+
94
+ const git: SimpleGit = simpleGit(repoPath);
95
+ await git.add([filePath]);
96
+ return ok(undefined);
97
+ } catch (e: any) {
98
+ return err(gitError(`Failed to write/stage '${filePath}'`, e));
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Returns the list of staged file paths.
104
+ * Used to verify only the intended path is staged before committing.
105
+ */
106
+ export async function getStagedFiles(repoPath: string): Promise<Result<string[]>> {
107
+ try {
108
+ const git: SimpleGit = simpleGit(repoPath);
109
+ const diff = await git.diff(["--cached", "--name-only"]);
110
+ const files = diff
111
+ .split("\n")
112
+ .map((l) => l.trim())
113
+ .filter(Boolean);
114
+ return ok(files);
115
+ } catch (e: any) {
116
+ return err(gitError("Failed to get staged files", e));
117
+ }
118
+ }
119
+
120
+ export async function commit(repoPath: string, message: string): Promise<Result<string>> {
121
+ try {
122
+ const git: SimpleGit = simpleGit(repoPath);
123
+ const result = await git.commit(message);
124
+ return ok(result.commit);
125
+ } catch (e: any) {
126
+ return err(gitError(`Failed to commit: ${e.message}`, e));
127
+ }
128
+ }
129
+
130
+ export async function push(
131
+ repoPath: string,
132
+ remote: string,
133
+ branchName: string
134
+ ): Promise<Result<void>> {
135
+ try {
136
+ const git: SimpleGit = simpleGit(repoPath);
137
+ // Non-force push only
138
+ await git.push([remote, `${branchName}:${branchName}`]);
139
+ return ok(undefined);
140
+ } catch (e: any) {
141
+ return err(gitError(`Failed to push '${branchName}' to '${remote}': ${e.message}`, e));
142
+ }
143
+ }
144
+
145
+ export async function getBranchSha(
146
+ repoPath: string,
147
+ branchName: string
148
+ ): Promise<Result<string>> {
149
+ try {
150
+ const git: SimpleGit = simpleGit(repoPath);
151
+ const sha = await git.revparse([branchName]);
152
+ return ok(sha.trim());
153
+ } catch (e: any) {
154
+ return err(gitError(`Failed to get SHA for '${branchName}'`, e));
155
+ }
156
+ }
157
+
158
+ export function createGitOps(): GitOps {
159
+ return {
160
+ ensureRepo,
161
+ checkoutBranch,
162
+ resetHard,
163
+ clean,
164
+ writeAndStage,
165
+ getStagedFiles,
166
+ commit,
167
+ push,
168
+ getBranchSha,
169
+ };
170
+ }
@@ -0,0 +1,148 @@
1
+ import { simpleGit, SimpleGit } from "simple-git";
2
+ import { Result, ok, err, BranchRef } from "../../core/types.js";
3
+ import { gitError } from "../../core/errors.js";
4
+
5
+ export type GitReader = {
6
+ readFileFromBranch(repoPath: string, branch: BranchRef, filePath: string): Promise<Result<string>>;
7
+ listBranches(repoPath: string, remote?: string): Promise<Result<BranchRef[]>>;
8
+ getCurrentBranch(repoPath: string): Promise<Result<string>>;
9
+ fetch(repoPath: string, remote?: string, prune?: boolean): Promise<Result<void>>;
10
+ getRemoteUrl(repoPath: string, remote?: string): Promise<Result<string>>;
11
+ };
12
+
13
+ export async function readFileFromBranch(
14
+ repoPath: string,
15
+ branch: BranchRef,
16
+ filePath: string
17
+ ): Promise<Result<string>> {
18
+ try {
19
+ const git: SimpleGit = simpleGit(repoPath);
20
+ // For remote branches, use the full remote ref; for local use name
21
+ const ref =
22
+ branch.isRemote && branch.remote
23
+ ? `${branch.remote}/${branch.name}`
24
+ : branch.name;
25
+ const content = await git.show([`${ref}:${filePath}`]);
26
+ return ok(content);
27
+ } catch (e: any) {
28
+ if (
29
+ e.message?.includes("does not exist") ||
30
+ e.message?.includes("fatal") ||
31
+ e.message?.includes("not a valid object")
32
+ ) {
33
+ return err(gitError(`File '${filePath}' not found in branch '${branch.name}'`, e));
34
+ }
35
+ return err(gitError(`Failed to read '${filePath}' from '${branch.name}'`, e));
36
+ }
37
+ }
38
+
39
+ export async function listBranches(
40
+ repoPath: string,
41
+ remote?: string
42
+ ): Promise<Result<BranchRef[]>> {
43
+ try {
44
+ const git: SimpleGit = simpleGit(repoPath);
45
+ const branches: BranchRef[] = [];
46
+ const seen = new Set<string>();
47
+
48
+ // Remote branches (source of truth for managed clone)
49
+ try {
50
+ const remoteBranches = await git.branch(["-r"]);
51
+ for (const fullName of remoteBranches.all) {
52
+ if (fullName.includes("HEAD")) continue;
53
+ const parts = fullName.trim().split("/");
54
+ if (parts.length < 2) continue;
55
+ const remoteName = parts[0];
56
+ const branchName = parts.slice(1).join("/");
57
+
58
+ if (remote && remoteName !== remote) continue;
59
+ if (seen.has(branchName)) continue;
60
+
61
+ try {
62
+ const sha = await git.revparse([fullName.trim()]);
63
+ seen.add(branchName);
64
+ branches.push({
65
+ name: branchName,
66
+ remote: remoteName,
67
+ sha: sha.trim(),
68
+ isRemote: true,
69
+ });
70
+ } catch {
71
+ // skip branches we can't resolve
72
+ }
73
+ }
74
+ } catch {
75
+ // No remotes is ok
76
+ }
77
+
78
+ // Local branches (only if not already discovered on remote)
79
+ try {
80
+ const local = await git.branchLocal();
81
+ for (const name of local.all) {
82
+ if (!seen.has(name)) {
83
+ const sha = await git.revparse([name]);
84
+ seen.add(name);
85
+ branches.push({ name, sha: sha.trim(), isRemote: false });
86
+ }
87
+ }
88
+ } catch {
89
+ // ignore
90
+ }
91
+
92
+ return ok(branches);
93
+ } catch (e: any) {
94
+ return err(gitError("Failed to list branches", e));
95
+ }
96
+ }
97
+
98
+ export async function getCurrentBranch(repoPath: string): Promise<Result<string>> {
99
+ try {
100
+ const git: SimpleGit = simpleGit(repoPath);
101
+ const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
102
+ return ok(branch.trim());
103
+ } catch (e: any) {
104
+ return err(gitError("Failed to get current branch", e));
105
+ }
106
+ }
107
+
108
+ export async function fetch(
109
+ repoPath: string,
110
+ remote: string = "origin",
111
+ prune: boolean = true
112
+ ): Promise<Result<void>> {
113
+ try {
114
+ const git: SimpleGit = simpleGit(repoPath);
115
+ const args = prune ? [remote, "--prune"] : [remote];
116
+ await git.fetch(args);
117
+ return ok(undefined);
118
+ } catch (e: any) {
119
+ return err(gitError(`Failed to fetch from '${remote}'`, e));
120
+ }
121
+ }
122
+
123
+ export async function getRemoteUrl(
124
+ repoPath: string,
125
+ remote: string = "origin"
126
+ ): Promise<Result<string>> {
127
+ try {
128
+ const git: SimpleGit = simpleGit(repoPath);
129
+ const remotes = await git.getRemotes(true);
130
+ const target = remotes.find((r) => r.name === remote) || remotes[0];
131
+ if (!target?.refs?.fetch) {
132
+ return err(gitError(`No remote '${remote}' found`));
133
+ }
134
+ return ok(target.refs.fetch);
135
+ } catch (e: any) {
136
+ return err(gitError("Failed to get remote URL", e));
137
+ }
138
+ }
139
+
140
+ export function createGitReader(): GitReader {
141
+ return {
142
+ readFileFromBranch,
143
+ listBranches,
144
+ getCurrentBranch,
145
+ fetch,
146
+ getRemoteUrl,
147
+ };
148
+ }
@@ -0,0 +1,104 @@
1
+ import { BranchResult, PushResult, BranchPlan } from "../../core/types.js";
2
+ import { AppConfig } from "../../config/schema.js";
3
+ import { writeFile, mkdir } from "fs/promises";
4
+ import { join } from "path";
5
+
6
+ export type RunReport = {
7
+ runId: string;
8
+ targetPath: string;
9
+ startedAt: string;
10
+ completedAt: string;
11
+ plans: BranchPlan[];
12
+ results: BranchResult[];
13
+ pushResults: PushResult[];
14
+ };
15
+
16
+ export type ReportService = {
17
+ saveJson(reportsDir: string, report: RunReport, config: AppConfig): Promise<void>;
18
+ saveMarkdown(reportsDir: string, report: RunReport, config: AppConfig): Promise<void>;
19
+ };
20
+
21
+ function redactContent(text: string): string {
22
+ // Replace value portions of assignments with [REDACTED]
23
+ return text.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.+$/gm, "$1=[REDACTED]");
24
+ }
25
+
26
+ export async function saveJson(
27
+ reportsDir: string,
28
+ report: RunReport,
29
+ config: AppConfig
30
+ ): Promise<void> {
31
+ if (!config.report.writeJson) return;
32
+ await mkdir(reportsDir, { recursive: true });
33
+ const filePath = join(reportsDir, `${report.runId}.json`);
34
+
35
+ let data = report;
36
+ if (config.report.redactValues) {
37
+ data = {
38
+ ...report,
39
+ plans: report.plans.map((p) => ({
40
+ ...p,
41
+ beforeContent: redactContent(p.beforeContent),
42
+ afterContent: redactContent(p.afterContent),
43
+ diff: redactContent(p.diff),
44
+ })),
45
+ };
46
+ }
47
+
48
+ await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
49
+ }
50
+
51
+ export async function saveMarkdown(
52
+ reportsDir: string,
53
+ report: RunReport,
54
+ config: AppConfig
55
+ ): Promise<void> {
56
+ if (!config.report.writeMarkdown) return;
57
+ await mkdir(reportsDir, { recursive: true });
58
+ const filePath = join(reportsDir, `${report.runId}.md`);
59
+
60
+ const statusEmoji: Record<string, string> = {
61
+ committed: "✅",
62
+ failed: "❌",
63
+ rejected: "⊘",
64
+ unchanged: "○",
65
+ skipped: "–",
66
+ };
67
+
68
+ const lines = [
69
+ `# Git Worker Run Report`,
70
+ ``,
71
+ `**Run ID:** ${report.runId} `,
72
+ `**Target:** ${report.targetPath} `,
73
+ `**Started:** ${report.startedAt} `,
74
+ `**Completed:** ${report.completedAt} `,
75
+ ``,
76
+ `## Results`,
77
+ ``,
78
+ `| Branch | Status | Commit |`,
79
+ `|--------|--------|--------|`,
80
+ ...report.results.map(
81
+ (r) =>
82
+ `| ${r.branch.name} | ${statusEmoji[r.status] ?? r.status} ${r.status} | ${r.commitSha ? `\`${r.commitSha.slice(0, 8)}\`` : "—"} |`
83
+ ),
84
+ ``,
85
+ ];
86
+
87
+ if (report.pushResults.length > 0) {
88
+ lines.push(`## Push Results`, ``);
89
+ lines.push(`| Branch | Status |`, `|--------|--------|`);
90
+ for (const pr of report.pushResults) {
91
+ lines.push(`| ${pr.branch.name} | ${pr.status} |`);
92
+ }
93
+ lines.push(``);
94
+ }
95
+
96
+ await writeFile(filePath, lines.join("\n"), "utf-8");
97
+ }
98
+
99
+ export function createReportService(): ReportService {
100
+ return {
101
+ saveJson,
102
+ saveMarkdown,
103
+ };
104
+ }
@@ -0,0 +1,80 @@
1
+ import { Result, ok, err, BranchPlan } from "../../core/types.js";
2
+ import { workspaceError } from "../../core/errors.js";
3
+ import { writeFile, mkdir } from "fs/promises";
4
+ import { join } from "path";
5
+ import { createPatch } from "diff";
6
+
7
+ export type ReviewService = {
8
+ writeSnapshots(
9
+ stagingDir: string,
10
+ runId: string,
11
+ plan: BranchPlan
12
+ ): Promise<Result<{ beforePath: string; afterPath: string }>>;
13
+ computeDiff(before: string, after: string, filePath: string): string;
14
+ };
15
+
16
+ /**
17
+ * Write before/after snapshots to the staging directory for a plan.
18
+ * Returns the file paths for opening in a diff viewer.
19
+ */
20
+ export async function writeSnapshots(
21
+ stagingDir: string,
22
+ runId: string,
23
+ plan: BranchPlan
24
+ ): Promise<Result<{ beforePath: string; afterPath: string }>> {
25
+ try {
26
+ const planDir = join(stagingDir, runId, plan.branch.name.replace(/\//g, "__"));
27
+ await mkdir(planDir, { recursive: true });
28
+
29
+ const beforePath = join(planDir, `before.${plan.path.split("/").pop()}`);
30
+ const afterPath = join(planDir, `after.${plan.path.split("/").pop()}`);
31
+
32
+ await writeFile(beforePath, plan.beforeContent, "utf-8");
33
+ await writeFile(afterPath, plan.afterContent, "utf-8");
34
+
35
+ return ok({ beforePath, afterPath });
36
+ } catch (e: any) {
37
+ return err(workspaceError(`Failed to write snapshots: ${e.message}`));
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Compute a unified diff string between before and after content.
43
+ * Uses the `diff` package for accurate line-level diffing.
44
+ */
45
+ export function computeDiff(
46
+ before: string,
47
+ after: string,
48
+ filePath: string
49
+ ): string {
50
+ if (before === after) return "";
51
+ return createPatch(filePath, before, after, "before", "after");
52
+ }
53
+
54
+ /**
55
+ * Format a diff for terminal display with color markers.
56
+ * Returns an array of lines with +/- prefixes for coloring.
57
+ */
58
+ export function parseDiffLines(
59
+ diff: string
60
+ ): Array<{ line: string; type: "added" | "removed" | "header" | "context" }> {
61
+ const lines = diff.split("\n");
62
+ return lines.map((line) => {
63
+ if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@")) {
64
+ return { line, type: "header" as const };
65
+ } else if (line.startsWith("+")) {
66
+ return { line, type: "added" as const };
67
+ } else if (line.startsWith("-")) {
68
+ return { line, type: "removed" as const };
69
+ } else {
70
+ return { line, type: "context" as const };
71
+ }
72
+ });
73
+ }
74
+
75
+ export function createReviewService(): ReviewService {
76
+ return {
77
+ writeSnapshots,
78
+ computeDiff,
79
+ };
80
+ }
@@ -0,0 +1,77 @@
1
+ import { Result, ok, err } from "../../core/types.js";
2
+ import { workspaceError } from "../../core/errors.js";
3
+ import { readFile, writeFile, mkdir } from "fs/promises";
4
+ import { join } from "path";
5
+ import { existsSync } from "fs";
6
+
7
+ export type TemplateState = {
8
+ schemaVersion: 1;
9
+ targetPath: string;
10
+ template: string;
11
+ updatedAt: string;
12
+ lastRunSummary?: {
13
+ committed: number;
14
+ failed: number;
15
+ skipped: number;
16
+ total: number;
17
+ };
18
+ };
19
+
20
+ export type StateService = {
21
+ loadTemplate(stateDir: string, targetPath: string): Promise<Result<TemplateState | null>>;
22
+ saveTemplate(stateDir: string, targetPath: string, templateRaw: string, summary?: TemplateState["lastRunSummary"]): Promise<Result<void>>;
23
+ };
24
+
25
+ function stateFileName(targetPath: string): string {
26
+ return targetPath.replace(/[/\\]/g, "__") + ".state.json";
27
+ }
28
+
29
+ export async function loadTemplate(
30
+ stateDir: string,
31
+ targetPath: string
32
+ ): Promise<Result<TemplateState | null>> {
33
+ const filePath = join(stateDir, stateFileName(targetPath));
34
+ if (!existsSync(filePath)) {
35
+ return ok(null);
36
+ }
37
+ try {
38
+ const raw = await readFile(filePath, "utf-8");
39
+ const data = JSON.parse(raw) as TemplateState;
40
+ if (data.schemaVersion !== 1) {
41
+ return ok(null); // Unknown schema — start fresh
42
+ }
43
+ return ok(data);
44
+ } catch (e: any) {
45
+ return err(workspaceError(`Failed to load template state: ${e.message}`));
46
+ }
47
+ }
48
+
49
+ export async function saveTemplate(
50
+ stateDir: string,
51
+ targetPath: string,
52
+ templateRaw: string,
53
+ summary?: TemplateState["lastRunSummary"]
54
+ ): Promise<Result<void>> {
55
+ try {
56
+ await mkdir(stateDir, { recursive: true });
57
+ const filePath = join(stateDir, stateFileName(targetPath));
58
+ const state: TemplateState = {
59
+ schemaVersion: 1,
60
+ targetPath,
61
+ template: templateRaw,
62
+ updatedAt: new Date().toISOString(),
63
+ ...(summary ? { lastRunSummary: summary } : {}),
64
+ };
65
+ await writeFile(filePath, JSON.stringify(state, null, 2), "utf-8");
66
+ return ok(undefined);
67
+ } catch (e: any) {
68
+ return err(workspaceError(`Failed to save template state: ${e.message}`));
69
+ }
70
+ }
71
+
72
+ export function createStateService(): StateService {
73
+ return {
74
+ loadTemplate,
75
+ saveTemplate,
76
+ };
77
+ }