@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,153 @@
|
|
|
1
|
+
export type Result<T, E = AppError> = {
|
|
2
|
+
ok: true;
|
|
3
|
+
value: T;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
error: E;
|
|
7
|
+
};
|
|
8
|
+
export type AppErrorCategory = "CONFIG" | "GIT" | "EDITOR" | "TEMPLATE" | "ELIGIBILITY" | "INPUT_CANCELLED" | "STALE_PLAN" | "APPLY" | "PUSH" | "WORKSPACE" | "VALIDATION";
|
|
9
|
+
export declare class AppError extends Error {
|
|
10
|
+
category: AppErrorCategory;
|
|
11
|
+
details?: unknown;
|
|
12
|
+
constructor(category: AppErrorCategory, message: string, details?: unknown);
|
|
13
|
+
}
|
|
14
|
+
export declare function ok<T>(value: T): Result<T>;
|
|
15
|
+
export declare function err<E extends AppError>(error: E): Result<never, E>;
|
|
16
|
+
export type BranchRef = {
|
|
17
|
+
name: string;
|
|
18
|
+
remote?: string;
|
|
19
|
+
sha: string;
|
|
20
|
+
isRemote: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type TargetFile = {
|
|
23
|
+
path: string;
|
|
24
|
+
};
|
|
25
|
+
export type TemplateEngineType = "env-reconcile" | "source-of-truth";
|
|
26
|
+
export type TargetTemplate = {
|
|
27
|
+
path: string;
|
|
28
|
+
engine: TemplateEngineType;
|
|
29
|
+
raw: string;
|
|
30
|
+
parsedEnv?: TemplateDocument;
|
|
31
|
+
variables?: Array<{
|
|
32
|
+
name: string;
|
|
33
|
+
fallback?: string;
|
|
34
|
+
raw: string;
|
|
35
|
+
}>;
|
|
36
|
+
isNewFile?: boolean;
|
|
37
|
+
};
|
|
38
|
+
export type ManagedProject = {
|
|
39
|
+
id: string;
|
|
40
|
+
sourceRepoPath: string;
|
|
41
|
+
remoteUrl?: string;
|
|
42
|
+
managedRepoPath: string;
|
|
43
|
+
stagingDir: string;
|
|
44
|
+
stateDir: string;
|
|
45
|
+
reportsDir: string;
|
|
46
|
+
markerPath: string;
|
|
47
|
+
isCached?: boolean;
|
|
48
|
+
};
|
|
49
|
+
export type TemplateDocument = {
|
|
50
|
+
raw: string;
|
|
51
|
+
assignments: TemplateAssignment[];
|
|
52
|
+
placeholders: string[];
|
|
53
|
+
};
|
|
54
|
+
export type TemplateAssignment = {
|
|
55
|
+
key: string;
|
|
56
|
+
value: string;
|
|
57
|
+
isStatic: boolean;
|
|
58
|
+
isPlaceholder: boolean;
|
|
59
|
+
placeholderName?: string;
|
|
60
|
+
originalLine: string;
|
|
61
|
+
};
|
|
62
|
+
export type EnvEntry = {
|
|
63
|
+
key: string;
|
|
64
|
+
value: string;
|
|
65
|
+
lineIndex: number;
|
|
66
|
+
originalLine: string;
|
|
67
|
+
hasExport: boolean;
|
|
68
|
+
};
|
|
69
|
+
export type BranchContent = {
|
|
70
|
+
branch: BranchRef;
|
|
71
|
+
exists: boolean;
|
|
72
|
+
content?: string;
|
|
73
|
+
error?: string;
|
|
74
|
+
};
|
|
75
|
+
export type MissingValueRequest = {
|
|
76
|
+
branch: BranchRef;
|
|
77
|
+
key: string;
|
|
78
|
+
};
|
|
79
|
+
export type BranchPlan = {
|
|
80
|
+
branch: BranchRef;
|
|
81
|
+
baseCommitSha: string;
|
|
82
|
+
path: string;
|
|
83
|
+
beforeContent: string;
|
|
84
|
+
beforeContentHash: string;
|
|
85
|
+
afterContent: string;
|
|
86
|
+
afterContentHash: string;
|
|
87
|
+
diff: string;
|
|
88
|
+
planHash: string;
|
|
89
|
+
status: "changed" | "unchanged" | "error";
|
|
90
|
+
reason?: string;
|
|
91
|
+
addedKeys: string[];
|
|
92
|
+
updatedKeys: string[];
|
|
93
|
+
missingVars?: string[];
|
|
94
|
+
isNewFile?: boolean;
|
|
95
|
+
};
|
|
96
|
+
export type Approval = {
|
|
97
|
+
branchName: string;
|
|
98
|
+
approved: boolean;
|
|
99
|
+
planHash: string;
|
|
100
|
+
};
|
|
101
|
+
export type BranchResult = {
|
|
102
|
+
branch: BranchRef;
|
|
103
|
+
status: "skipped" | "rejected" | "unchanged" | "committed" | "failed";
|
|
104
|
+
commitSha?: string;
|
|
105
|
+
reason?: string;
|
|
106
|
+
};
|
|
107
|
+
export type PushResult = {
|
|
108
|
+
branch: BranchRef;
|
|
109
|
+
status: "pushed" | "skipped" | "failed";
|
|
110
|
+
reason?: string;
|
|
111
|
+
};
|
|
112
|
+
export type CliInput = {
|
|
113
|
+
repo?: string;
|
|
114
|
+
cached?: boolean;
|
|
115
|
+
branch?: string;
|
|
116
|
+
invertBranch?: boolean;
|
|
117
|
+
file?: string;
|
|
118
|
+
editor?: string;
|
|
119
|
+
commitMsg?: string;
|
|
120
|
+
push?: "prompt" | "always" | "never";
|
|
121
|
+
remote?: string;
|
|
122
|
+
autoApprove?: boolean;
|
|
123
|
+
ci?: boolean;
|
|
124
|
+
};
|
|
125
|
+
export type AppServices = {
|
|
126
|
+
git: import("../services/git/reader.js").GitReader;
|
|
127
|
+
gitOps: import("../services/git/ops.js").GitOps;
|
|
128
|
+
workspace: import("../services/workspace/manager.js").WorkspaceManager;
|
|
129
|
+
template: import("../services/template/transform.js").TemplateService;
|
|
130
|
+
state: import("../services/state/index.js").StateService;
|
|
131
|
+
editor: import("../services/editor/index.js").EditorService;
|
|
132
|
+
review: import("../services/review/index.js").ReviewService;
|
|
133
|
+
};
|
|
134
|
+
export type AppDeps = {
|
|
135
|
+
config: import("../config/schema.js").AppConfig;
|
|
136
|
+
services: AppServices;
|
|
137
|
+
};
|
|
138
|
+
export type RunContext = {
|
|
139
|
+
runId: string;
|
|
140
|
+
input: CliInput;
|
|
141
|
+
project?: ManagedProject;
|
|
142
|
+
selectedBranches?: BranchRef[];
|
|
143
|
+
target?: TargetFile;
|
|
144
|
+
targets?: TargetFile[];
|
|
145
|
+
eligible?: BranchRef[];
|
|
146
|
+
template?: TemplateDocument;
|
|
147
|
+
activeTemplate?: TargetTemplate;
|
|
148
|
+
templates?: TargetTemplate[];
|
|
149
|
+
plans?: BranchPlan[];
|
|
150
|
+
approvals?: Approval[];
|
|
151
|
+
results?: BranchResult[];
|
|
152
|
+
pushResults?: PushResult[];
|
|
153
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
category;
|
|
3
|
+
details;
|
|
4
|
+
constructor(category, message, details) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "AppError";
|
|
7
|
+
this.category = category;
|
|
8
|
+
this.details = details;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function ok(value) {
|
|
12
|
+
return { ok: true, value };
|
|
13
|
+
}
|
|
14
|
+
export function err(error) {
|
|
15
|
+
return { ok: false, error };
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Result } from "../../core/types.js";
|
|
2
|
+
import { AppConfig } from "../../config/schema.js";
|
|
3
|
+
export type EditorService = {
|
|
4
|
+
openForEdit(templatePath: string, config: AppConfig): Promise<Result<void>>;
|
|
5
|
+
openForDiff(beforePath: string, afterPath: string, config: AppConfig): Promise<Result<void>>;
|
|
6
|
+
};
|
|
7
|
+
export declare function openForEdit(templatePath: string, config: AppConfig): Promise<Result<void>>;
|
|
8
|
+
export declare function openForDiff(beforePath: string, afterPath: string, config: AppConfig): Promise<Result<void>>;
|
|
9
|
+
export declare function createEditorService(): EditorService;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ok, err } from "../../core/types.js";
|
|
2
|
+
import { editorError } from "../../core/errors.js";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
/**
|
|
5
|
+
* Substitute {file}, {before}, {after} placeholders in a command template.
|
|
6
|
+
*/
|
|
7
|
+
function buildCommand(template, vars) {
|
|
8
|
+
return template.map((part) => {
|
|
9
|
+
let result = part;
|
|
10
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
11
|
+
result = result.replace(`{${k}}`, v);
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Spawn a command and wait for it to exit.
|
|
18
|
+
* Returns an error if the process exits with a non-zero code.
|
|
19
|
+
*/
|
|
20
|
+
async function runCommand(cmd, description) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const [bin, ...args] = cmd;
|
|
23
|
+
const proc = spawn(bin, args, { stdio: "inherit" });
|
|
24
|
+
proc.on("error", (e) => {
|
|
25
|
+
if (e.code === "ENOENT") {
|
|
26
|
+
resolve(err(editorError(`Editor '${bin}' not found. Install it or set a different editor via --editor or git-worker.config.json.`)));
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
resolve(err(editorError(`${description} failed: ${e.message}`)));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
proc.on("close", (code) => {
|
|
33
|
+
if (code === 0) {
|
|
34
|
+
resolve(ok(undefined));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
// Some editors (like VS Code) return non-zero on cancel
|
|
38
|
+
resolve(ok(undefined));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export async function openForEdit(templatePath, config) {
|
|
44
|
+
const cmd = buildCommand(config.editor.editCommand, { file: templatePath });
|
|
45
|
+
return runCommand(cmd, "Editor");
|
|
46
|
+
}
|
|
47
|
+
export async function openForDiff(beforePath, afterPath, config) {
|
|
48
|
+
const cmd = buildCommand(config.editor.diffCommand, {
|
|
49
|
+
before: beforePath,
|
|
50
|
+
after: afterPath,
|
|
51
|
+
});
|
|
52
|
+
return runCommand(cmd, "Diff viewer");
|
|
53
|
+
}
|
|
54
|
+
export function createEditorService() {
|
|
55
|
+
return {
|
|
56
|
+
openForEdit,
|
|
57
|
+
openForDiff,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Result } from "../../core/types.js";
|
|
2
|
+
export type GitOps = {
|
|
3
|
+
ensureRepo(repoPath: string): Promise<Result<void>>;
|
|
4
|
+
checkoutBranch(repoPath: string, branchName: string): Promise<Result<void>>;
|
|
5
|
+
resetHard(repoPath: string): Promise<Result<void>>;
|
|
6
|
+
clean(repoPath: string, mode?: "tracked-and-untracked" | "tracked-only"): Promise<Result<void>>;
|
|
7
|
+
writeAndStage(repoPath: string, filePath: string, content: string): Promise<Result<void>>;
|
|
8
|
+
getStagedFiles(repoPath: string): Promise<Result<string[]>>;
|
|
9
|
+
commit(repoPath: string, message: string): Promise<Result<string>>;
|
|
10
|
+
push(repoPath: string, remote: string, branchName: string): Promise<Result<void>>;
|
|
11
|
+
getBranchSha(repoPath: string, branchName: string): Promise<Result<string>>;
|
|
12
|
+
};
|
|
13
|
+
export declare function ensureRepo(repoPath: string): Promise<Result<void>>;
|
|
14
|
+
export declare function checkoutBranch(repoPath: string, branchName: string): Promise<Result<void>>;
|
|
15
|
+
export declare function resetHard(repoPath: string): Promise<Result<void>>;
|
|
16
|
+
export declare function clean(repoPath: string, mode?: "tracked-and-untracked" | "tracked-only"): Promise<Result<void>>;
|
|
17
|
+
/**
|
|
18
|
+
* Atomically write a file (via temp file + rename) and stage it.
|
|
19
|
+
* Only stages the specific path — no glob or wildcard.
|
|
20
|
+
*/
|
|
21
|
+
export declare function writeAndStage(repoPath: string, filePath: string, content: string): Promise<Result<void>>;
|
|
22
|
+
/**
|
|
23
|
+
* Returns the list of staged file paths.
|
|
24
|
+
* Used to verify only the intended path is staged before committing.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getStagedFiles(repoPath: string): Promise<Result<string[]>>;
|
|
27
|
+
export declare function commit(repoPath: string, message: string): Promise<Result<string>>;
|
|
28
|
+
export declare function push(repoPath: string, remote: string, branchName: string): Promise<Result<void>>;
|
|
29
|
+
export declare function getBranchSha(repoPath: string, branchName: string): Promise<Result<string>>;
|
|
30
|
+
export declare function createGitOps(): GitOps;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { simpleGit } from "simple-git";
|
|
2
|
+
import { ok, err } from "../../core/types.js";
|
|
3
|
+
import { gitError } from "../../core/errors.js";
|
|
4
|
+
import { writeFile, mkdir, rename } from "fs/promises";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
export async function ensureRepo(repoPath) {
|
|
7
|
+
try {
|
|
8
|
+
const git = simpleGit(repoPath);
|
|
9
|
+
const isRepo = await git.checkIsRepo();
|
|
10
|
+
if (!isRepo)
|
|
11
|
+
return err(gitError("Not a git repository"));
|
|
12
|
+
return ok(undefined);
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
return err(gitError("Failed to check repository", e));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function checkoutBranch(repoPath, branchName) {
|
|
19
|
+
try {
|
|
20
|
+
const git = simpleGit(repoPath);
|
|
21
|
+
// Align local branch to origin/branchName if remote branch exists
|
|
22
|
+
try {
|
|
23
|
+
await git.checkout(["-B", branchName, `origin/${branchName}`]);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Fallback for local-only branch
|
|
27
|
+
await git.checkout(branchName);
|
|
28
|
+
}
|
|
29
|
+
return ok(undefined);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
return err(gitError(`Failed to checkout '${branchName}'`, e));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function resetHard(repoPath) {
|
|
36
|
+
try {
|
|
37
|
+
const git = simpleGit(repoPath);
|
|
38
|
+
await git.reset(["--hard"]);
|
|
39
|
+
return ok(undefined);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return err(gitError("Failed to reset --hard", e));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function clean(repoPath, mode = "tracked-and-untracked") {
|
|
46
|
+
try {
|
|
47
|
+
const git = simpleGit(repoPath);
|
|
48
|
+
if (mode === "tracked-and-untracked") {
|
|
49
|
+
await git.clean("f", ["-d"]);
|
|
50
|
+
}
|
|
51
|
+
// tracked-only: git reset --hard already handles it
|
|
52
|
+
return ok(undefined);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return err(gitError("Failed to clean working tree", e));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Atomically write a file (via temp file + rename) and stage it.
|
|
60
|
+
* Only stages the specific path — no glob or wildcard.
|
|
61
|
+
*/
|
|
62
|
+
export async function writeAndStage(repoPath, filePath, content) {
|
|
63
|
+
try {
|
|
64
|
+
const fullPath = join(repoPath, filePath);
|
|
65
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
66
|
+
// Atomic write: write to temp, then rename
|
|
67
|
+
const tmp = fullPath + ".git-worker-tmp";
|
|
68
|
+
await writeFile(tmp, content, "utf-8");
|
|
69
|
+
await rename(tmp, fullPath);
|
|
70
|
+
const git = simpleGit(repoPath);
|
|
71
|
+
await git.add([filePath]);
|
|
72
|
+
return ok(undefined);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
return err(gitError(`Failed to write/stage '${filePath}'`, e));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Returns the list of staged file paths.
|
|
80
|
+
* Used to verify only the intended path is staged before committing.
|
|
81
|
+
*/
|
|
82
|
+
export async function getStagedFiles(repoPath) {
|
|
83
|
+
try {
|
|
84
|
+
const git = simpleGit(repoPath);
|
|
85
|
+
const diff = await git.diff(["--cached", "--name-only"]);
|
|
86
|
+
const files = diff
|
|
87
|
+
.split("\n")
|
|
88
|
+
.map((l) => l.trim())
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
return ok(files);
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
return err(gitError("Failed to get staged files", e));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function commit(repoPath, message) {
|
|
97
|
+
try {
|
|
98
|
+
const git = simpleGit(repoPath);
|
|
99
|
+
const result = await git.commit(message);
|
|
100
|
+
return ok(result.commit);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
return err(gitError(`Failed to commit: ${e.message}`, e));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export async function push(repoPath, remote, branchName) {
|
|
107
|
+
try {
|
|
108
|
+
const git = simpleGit(repoPath);
|
|
109
|
+
// Non-force push only
|
|
110
|
+
await git.push([remote, `${branchName}:${branchName}`]);
|
|
111
|
+
return ok(undefined);
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
return err(gitError(`Failed to push '${branchName}' to '${remote}': ${e.message}`, e));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export async function getBranchSha(repoPath, branchName) {
|
|
118
|
+
try {
|
|
119
|
+
const git = simpleGit(repoPath);
|
|
120
|
+
const sha = await git.revparse([branchName]);
|
|
121
|
+
return ok(sha.trim());
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
return err(gitError(`Failed to get SHA for '${branchName}'`, e));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function createGitOps() {
|
|
128
|
+
return {
|
|
129
|
+
ensureRepo,
|
|
130
|
+
checkoutBranch,
|
|
131
|
+
resetHard,
|
|
132
|
+
clean,
|
|
133
|
+
writeAndStage,
|
|
134
|
+
getStagedFiles,
|
|
135
|
+
commit,
|
|
136
|
+
push,
|
|
137
|
+
getBranchSha,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Result, BranchRef } from "../../core/types.js";
|
|
2
|
+
export type GitReader = {
|
|
3
|
+
readFileFromBranch(repoPath: string, branch: BranchRef, filePath: string): Promise<Result<string>>;
|
|
4
|
+
listBranches(repoPath: string, remote?: string): Promise<Result<BranchRef[]>>;
|
|
5
|
+
getCurrentBranch(repoPath: string): Promise<Result<string>>;
|
|
6
|
+
fetch(repoPath: string, remote?: string, prune?: boolean): Promise<Result<void>>;
|
|
7
|
+
getRemoteUrl(repoPath: string, remote?: string): Promise<Result<string>>;
|
|
8
|
+
};
|
|
9
|
+
export declare function readFileFromBranch(repoPath: string, branch: BranchRef, filePath: string): Promise<Result<string>>;
|
|
10
|
+
export declare function listBranches(repoPath: string, remote?: string): Promise<Result<BranchRef[]>>;
|
|
11
|
+
export declare function getCurrentBranch(repoPath: string): Promise<Result<string>>;
|
|
12
|
+
export declare function fetch(repoPath: string, remote?: string, prune?: boolean): Promise<Result<void>>;
|
|
13
|
+
export declare function getRemoteUrl(repoPath: string, remote?: string): Promise<Result<string>>;
|
|
14
|
+
export declare function createGitReader(): GitReader;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { simpleGit } from "simple-git";
|
|
2
|
+
import { ok, err } from "../../core/types.js";
|
|
3
|
+
import { gitError } from "../../core/errors.js";
|
|
4
|
+
export async function readFileFromBranch(repoPath, branch, filePath) {
|
|
5
|
+
try {
|
|
6
|
+
const git = simpleGit(repoPath);
|
|
7
|
+
// For remote branches, use the full remote ref; for local use name
|
|
8
|
+
const ref = branch.isRemote && branch.remote
|
|
9
|
+
? `${branch.remote}/${branch.name}`
|
|
10
|
+
: branch.name;
|
|
11
|
+
const content = await git.show([`${ref}:${filePath}`]);
|
|
12
|
+
return ok(content);
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
if (e.message?.includes("does not exist") ||
|
|
16
|
+
e.message?.includes("fatal") ||
|
|
17
|
+
e.message?.includes("not a valid object")) {
|
|
18
|
+
return err(gitError(`File '${filePath}' not found in branch '${branch.name}'`, e));
|
|
19
|
+
}
|
|
20
|
+
return err(gitError(`Failed to read '${filePath}' from '${branch.name}'`, e));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function listBranches(repoPath, remote) {
|
|
24
|
+
try {
|
|
25
|
+
const git = simpleGit(repoPath);
|
|
26
|
+
const branches = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
// Remote branches (source of truth for managed clone)
|
|
29
|
+
try {
|
|
30
|
+
const remoteBranches = await git.branch(["-r"]);
|
|
31
|
+
for (const fullName of remoteBranches.all) {
|
|
32
|
+
if (fullName.includes("HEAD"))
|
|
33
|
+
continue;
|
|
34
|
+
const parts = fullName.trim().split("/");
|
|
35
|
+
if (parts.length < 2)
|
|
36
|
+
continue;
|
|
37
|
+
const remoteName = parts[0];
|
|
38
|
+
const branchName = parts.slice(1).join("/");
|
|
39
|
+
if (remote && remoteName !== remote)
|
|
40
|
+
continue;
|
|
41
|
+
if (seen.has(branchName))
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
const sha = await git.revparse([fullName.trim()]);
|
|
45
|
+
seen.add(branchName);
|
|
46
|
+
branches.push({
|
|
47
|
+
name: branchName,
|
|
48
|
+
remote: remoteName,
|
|
49
|
+
sha: sha.trim(),
|
|
50
|
+
isRemote: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// skip branches we can't resolve
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// No remotes is ok
|
|
60
|
+
}
|
|
61
|
+
// Local branches (only if not already discovered on remote)
|
|
62
|
+
try {
|
|
63
|
+
const local = await git.branchLocal();
|
|
64
|
+
for (const name of local.all) {
|
|
65
|
+
if (!seen.has(name)) {
|
|
66
|
+
const sha = await git.revparse([name]);
|
|
67
|
+
seen.add(name);
|
|
68
|
+
branches.push({ name, sha: sha.trim(), isRemote: false });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// ignore
|
|
74
|
+
}
|
|
75
|
+
return ok(branches);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
return err(gitError("Failed to list branches", e));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function getCurrentBranch(repoPath) {
|
|
82
|
+
try {
|
|
83
|
+
const git = simpleGit(repoPath);
|
|
84
|
+
const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
|
|
85
|
+
return ok(branch.trim());
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
return err(gitError("Failed to get current branch", e));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export async function fetch(repoPath, remote = "origin", prune = true) {
|
|
92
|
+
try {
|
|
93
|
+
const git = simpleGit(repoPath);
|
|
94
|
+
const args = prune ? [remote, "--prune"] : [remote];
|
|
95
|
+
await git.fetch(args);
|
|
96
|
+
return ok(undefined);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
return err(gitError(`Failed to fetch from '${remote}'`, e));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function getRemoteUrl(repoPath, remote = "origin") {
|
|
103
|
+
try {
|
|
104
|
+
const git = simpleGit(repoPath);
|
|
105
|
+
const remotes = await git.getRemotes(true);
|
|
106
|
+
const target = remotes.find((r) => r.name === remote) || remotes[0];
|
|
107
|
+
if (!target?.refs?.fetch) {
|
|
108
|
+
return err(gitError(`No remote '${remote}' found`));
|
|
109
|
+
}
|
|
110
|
+
return ok(target.refs.fetch);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
return err(gitError("Failed to get remote URL", e));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function createGitReader() {
|
|
117
|
+
return {
|
|
118
|
+
readFileFromBranch,
|
|
119
|
+
listBranches,
|
|
120
|
+
getCurrentBranch,
|
|
121
|
+
fetch,
|
|
122
|
+
getRemoteUrl,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BranchResult, PushResult, BranchPlan } from "../../core/types.js";
|
|
2
|
+
import { AppConfig } from "../../config/schema.js";
|
|
3
|
+
export type RunReport = {
|
|
4
|
+
runId: string;
|
|
5
|
+
targetPath: string;
|
|
6
|
+
startedAt: string;
|
|
7
|
+
completedAt: string;
|
|
8
|
+
plans: BranchPlan[];
|
|
9
|
+
results: BranchResult[];
|
|
10
|
+
pushResults: PushResult[];
|
|
11
|
+
};
|
|
12
|
+
export type ReportService = {
|
|
13
|
+
saveJson(reportsDir: string, report: RunReport, config: AppConfig): Promise<void>;
|
|
14
|
+
saveMarkdown(reportsDir: string, report: RunReport, config: AppConfig): Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
export declare function saveJson(reportsDir: string, report: RunReport, config: AppConfig): Promise<void>;
|
|
17
|
+
export declare function saveMarkdown(reportsDir: string, report: RunReport, config: AppConfig): Promise<void>;
|
|
18
|
+
export declare function createReportService(): ReportService;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
function redactContent(text) {
|
|
4
|
+
// Replace value portions of assignments with [REDACTED]
|
|
5
|
+
return text.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.+$/gm, "$1=[REDACTED]");
|
|
6
|
+
}
|
|
7
|
+
export async function saveJson(reportsDir, report, config) {
|
|
8
|
+
if (!config.report.writeJson)
|
|
9
|
+
return;
|
|
10
|
+
await mkdir(reportsDir, { recursive: true });
|
|
11
|
+
const filePath = join(reportsDir, `${report.runId}.json`);
|
|
12
|
+
let data = report;
|
|
13
|
+
if (config.report.redactValues) {
|
|
14
|
+
data = {
|
|
15
|
+
...report,
|
|
16
|
+
plans: report.plans.map((p) => ({
|
|
17
|
+
...p,
|
|
18
|
+
beforeContent: redactContent(p.beforeContent),
|
|
19
|
+
afterContent: redactContent(p.afterContent),
|
|
20
|
+
diff: redactContent(p.diff),
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
25
|
+
}
|
|
26
|
+
export async function saveMarkdown(reportsDir, report, config) {
|
|
27
|
+
if (!config.report.writeMarkdown)
|
|
28
|
+
return;
|
|
29
|
+
await mkdir(reportsDir, { recursive: true });
|
|
30
|
+
const filePath = join(reportsDir, `${report.runId}.md`);
|
|
31
|
+
const statusEmoji = {
|
|
32
|
+
committed: "✅",
|
|
33
|
+
failed: "❌",
|
|
34
|
+
rejected: "⊘",
|
|
35
|
+
unchanged: "○",
|
|
36
|
+
skipped: "–",
|
|
37
|
+
};
|
|
38
|
+
const lines = [
|
|
39
|
+
`# Git Worker Run Report`,
|
|
40
|
+
``,
|
|
41
|
+
`**Run ID:** ${report.runId} `,
|
|
42
|
+
`**Target:** ${report.targetPath} `,
|
|
43
|
+
`**Started:** ${report.startedAt} `,
|
|
44
|
+
`**Completed:** ${report.completedAt} `,
|
|
45
|
+
``,
|
|
46
|
+
`## Results`,
|
|
47
|
+
``,
|
|
48
|
+
`| Branch | Status | Commit |`,
|
|
49
|
+
`|--------|--------|--------|`,
|
|
50
|
+
...report.results.map((r) => `| ${r.branch.name} | ${statusEmoji[r.status] ?? r.status} ${r.status} | ${r.commitSha ? `\`${r.commitSha.slice(0, 8)}\`` : "—"} |`),
|
|
51
|
+
``,
|
|
52
|
+
];
|
|
53
|
+
if (report.pushResults.length > 0) {
|
|
54
|
+
lines.push(`## Push Results`, ``);
|
|
55
|
+
lines.push(`| Branch | Status |`, `|--------|--------|`);
|
|
56
|
+
for (const pr of report.pushResults) {
|
|
57
|
+
lines.push(`| ${pr.branch.name} | ${pr.status} |`);
|
|
58
|
+
}
|
|
59
|
+
lines.push(``);
|
|
60
|
+
}
|
|
61
|
+
await writeFile(filePath, lines.join("\n"), "utf-8");
|
|
62
|
+
}
|
|
63
|
+
export function createReportService() {
|
|
64
|
+
return {
|
|
65
|
+
saveJson,
|
|
66
|
+
saveMarkdown,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Result, BranchPlan } from "../../core/types.js";
|
|
2
|
+
export type ReviewService = {
|
|
3
|
+
writeSnapshots(stagingDir: string, runId: string, plan: BranchPlan): Promise<Result<{
|
|
4
|
+
beforePath: string;
|
|
5
|
+
afterPath: string;
|
|
6
|
+
}>>;
|
|
7
|
+
computeDiff(before: string, after: string, filePath: string): string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Write before/after snapshots to the staging directory for a plan.
|
|
11
|
+
* Returns the file paths for opening in a diff viewer.
|
|
12
|
+
*/
|
|
13
|
+
export declare function writeSnapshots(stagingDir: string, runId: string, plan: BranchPlan): Promise<Result<{
|
|
14
|
+
beforePath: string;
|
|
15
|
+
afterPath: string;
|
|
16
|
+
}>>;
|
|
17
|
+
/**
|
|
18
|
+
* Compute a unified diff string between before and after content.
|
|
19
|
+
* Uses the `diff` package for accurate line-level diffing.
|
|
20
|
+
*/
|
|
21
|
+
export declare function computeDiff(before: string, after: string, filePath: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Format a diff for terminal display with color markers.
|
|
24
|
+
* Returns an array of lines with +/- prefixes for coloring.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseDiffLines(diff: string): Array<{
|
|
27
|
+
line: string;
|
|
28
|
+
type: "added" | "removed" | "header" | "context";
|
|
29
|
+
}>;
|
|
30
|
+
export declare function createReviewService(): ReviewService;
|