@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.
- package/GIT_WORKER_PROJECT_PLAN.md +499 -0
- package/README.md +114 -2
- package/demo/README.md +58 -0
- package/demo/index.ts +526 -0
- package/dist/app.d.ts +2 -0
- package/dist/app.js +48 -0
- package/dist/cli/commands.d.ts +6 -0
- package/dist/cli/commands.js +111 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +115 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +44 -0
- package/dist/config/load.d.ts +3 -0
- package/dist/config/load.js +65 -0
- package/dist/config/schema.d.ts +200 -0
- package/dist/config/schema.js +45 -0
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/pipeline.d.ts +3 -0
- package/dist/core/pipeline.js +14 -0
- package/dist/core/types.d.ts +153 -0
- package/dist/core/types.js +16 -0
- package/dist/services/editor/index.d.ts +9 -0
- package/dist/services/editor/index.js +59 -0
- package/dist/services/git/ops.d.ts +30 -0
- package/dist/services/git/ops.js +139 -0
- package/dist/services/git/reader.d.ts +14 -0
- package/dist/services/git/reader.js +124 -0
- package/dist/services/report/index.d.ts +18 -0
- package/dist/services/report/index.js +68 -0
- package/dist/services/review/index.d.ts +30 -0
- package/dist/services/review/index.js +59 -0
- package/dist/services/state/index.d.ts +20 -0
- package/dist/services/state/index.js +49 -0
- package/dist/services/template/env.d.ts +23 -0
- package/dist/services/template/env.js +146 -0
- package/dist/services/template/interpolator.d.ts +30 -0
- package/dist/services/template/interpolator.js +75 -0
- package/dist/services/template/transform.d.ts +22 -0
- package/dist/services/template/transform.js +75 -0
- package/dist/services/template/validator.d.ts +5 -0
- package/dist/services/template/validator.js +68 -0
- package/dist/services/workspace/cache.d.ts +32 -0
- package/dist/services/workspace/cache.js +107 -0
- package/dist/services/workspace/manager.d.ts +44 -0
- package/dist/services/workspace/manager.js +301 -0
- package/dist/stages/apply.d.ts +3 -0
- package/dist/stages/apply.js +161 -0
- package/dist/stages/edit-template.d.ts +3 -0
- package/dist/stages/edit-template.js +198 -0
- package/dist/stages/eligibility.d.ts +3 -0
- package/dist/stages/eligibility.js +44 -0
- package/dist/stages/load-template.d.ts +3 -0
- package/dist/stages/load-template.js +168 -0
- package/dist/stages/plan.d.ts +3 -0
- package/dist/stages/plan.js +270 -0
- package/dist/stages/push.d.ts +3 -0
- package/dist/stages/push.js +99 -0
- package/dist/stages/report.d.ts +3 -0
- package/dist/stages/report.js +103 -0
- package/dist/stages/resolve-project.d.ts +3 -0
- package/dist/stages/resolve-project.js +294 -0
- package/dist/stages/review.d.ts +3 -0
- package/dist/stages/review.js +197 -0
- package/dist/stages/select.d.ts +13 -0
- package/dist/stages/select.js +250 -0
- package/dist/stages/sync.d.ts +3 -0
- package/dist/stages/sync.js +47 -0
- package/dist/utils/hash.d.ts +2 -0
- package/dist/utils/hash.js +7 -0
- package/dist/utils/ui.d.ts +21 -0
- package/dist/utils/ui.js +113 -0
- package/dist/utils/which.d.ts +5 -0
- package/dist/utils/which.js +16 -0
- package/docs/FEATURES.md +84 -0
- package/docs/SERVICES.md +57 -0
- package/docs/architecture.md +38 -0
- package/docs/usage.md +337 -0
- package/package.json +48 -4
- package/src/app.ts +69 -0
- package/src/cli/commands.ts +142 -0
- package/src/cli/index.ts +123 -0
- package/src/config/defaults.ts +46 -0
- package/src/config/load.ts +70 -0
- package/src/config/schema.ts +48 -0
- package/src/core/errors.ts +45 -0
- package/src/core/pipeline.ts +17 -0
- package/src/core/types.ts +188 -0
- package/src/services/editor/index.ts +85 -0
- package/src/services/git/ops.ts +170 -0
- package/src/services/git/reader.ts +148 -0
- package/src/services/report/index.ts +104 -0
- package/src/services/review/index.ts +80 -0
- package/src/services/state/index.ts +77 -0
- package/src/services/template/env.ts +169 -0
- package/src/services/template/interpolator.ts +109 -0
- package/src/services/template/transform.ts +97 -0
- package/src/services/template/validator.ts +75 -0
- package/src/services/workspace/cache.ts +144 -0
- package/src/services/workspace/manager.ts +370 -0
- package/src/stages/apply.ts +194 -0
- package/src/stages/edit-template.ts +227 -0
- package/src/stages/eligibility.ts +67 -0
- package/src/stages/load-template.ts +188 -0
- package/src/stages/plan.ts +362 -0
- package/src/stages/push.ts +120 -0
- package/src/stages/report.ts +126 -0
- package/src/stages/resolve-project.ts +341 -0
- package/src/stages/review.ts +228 -0
- package/src/stages/select.ts +302 -0
- package/src/stages/sync.ts +63 -0
- package/src/utils/hash.ts +15 -0
- package/src/utils/ui.ts +137 -0
- package/src/utils/which.ts +18 -0
- 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
|
+
}
|