@samaksh/git-worker 0.2.3 → 0.3.1
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/dist/cli/index.js +12 -1
- package/dist/config/repo-presets.d.ts +16 -0
- package/dist/config/repo-presets.js +271 -0
- package/dist/core/types.d.ts +6 -0
- package/dist/stages/apply.js +92 -71
- package/dist/stages/edit-template.js +167 -111
- package/dist/stages/eligibility.js +45 -16
- package/dist/stages/load-template.js +109 -94
- package/dist/stages/plan.js +231 -187
- package/dist/stages/report.js +23 -18
- package/dist/stages/review.js +129 -111
- package/dist/stages/select.js +115 -15
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
+
import { createRequire } from "module";
|
|
3
4
|
import { run } from "../app.js";
|
|
4
5
|
import { handleCacheList, handleCacheClean } from "./commands.js";
|
|
5
6
|
import chalk from "chalk";
|
|
7
|
+
let version = "0.3.1";
|
|
8
|
+
try {
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const pkg = require("../../package.json");
|
|
11
|
+
if (pkg?.version)
|
|
12
|
+
version = pkg.version;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// fallback
|
|
16
|
+
}
|
|
6
17
|
const program = new Command();
|
|
7
18
|
program
|
|
8
19
|
.name("git-worker")
|
|
9
20
|
.description("Safe, composable CLI/TUI for applying reviewed file changes across many Git branches")
|
|
10
|
-
.version(
|
|
21
|
+
.version(version)
|
|
11
22
|
.option("-r, --repo <path>", "Repository path or remote URL (default: current directory)")
|
|
12
23
|
.option("-c, --cached", "Select from previously cloned and cached repositories")
|
|
13
24
|
.option("-b, --branch <pattern>", "Branch pattern — glob (uat/*) or regex (/uat.*/)")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type RepoPresetFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
description: string;
|
|
4
|
+
isDynamic?: boolean;
|
|
5
|
+
dynamicVar?: string;
|
|
6
|
+
defaultContent: string;
|
|
7
|
+
};
|
|
8
|
+
export type RepoPreset = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
matchRemote: RegExp | string[];
|
|
12
|
+
matchName?: RegExp | string[];
|
|
13
|
+
files: RepoPresetFile[];
|
|
14
|
+
};
|
|
15
|
+
export declare const REPO_PRESETS: RepoPreset[];
|
|
16
|
+
export declare function detectPreset(remoteUrl?: string, repoName?: string): RepoPreset | undefined;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
export const REPO_PRESETS = [
|
|
2
|
+
{
|
|
3
|
+
id: "clomodel",
|
|
4
|
+
name: "CLO Model (ACEBRE_CLOmodel)",
|
|
5
|
+
matchRemote: [
|
|
6
|
+
"https://github.hdfcbank.com/HDFCBANK/ACEBRE_CLOmodel.git",
|
|
7
|
+
"git@github.hdfcbank.com:HDFCBANK/ACEBRE_CLOmodel.git",
|
|
8
|
+
"acebre_clomodel.git",
|
|
9
|
+
"ACEBRE_CLOmodel.git",
|
|
10
|
+
"clomodel",
|
|
11
|
+
],
|
|
12
|
+
matchName: ["ACEBRE_CLOmodel", "CLOmodel", "clomodel"],
|
|
13
|
+
files: [
|
|
14
|
+
{
|
|
15
|
+
path: ".env",
|
|
16
|
+
description: "Environment variables configuration",
|
|
17
|
+
defaultContent: `# CLO Model Environment Configuration
|
|
18
|
+
SERVICE_NAME={SERVICE_NAME}
|
|
19
|
+
servicename={servicename}
|
|
20
|
+
ENV={ENV:-uat}
|
|
21
|
+
PORT={PORT:-8080}
|
|
22
|
+
LOG_LEVEL={LOG_LEVEL:-INFO}
|
|
23
|
+
MODEL_VERSION={MODEL_VERSION:-1.0.0}
|
|
24
|
+
`,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
path: "Dockerfile_model",
|
|
28
|
+
description: "Model service Dockerfile",
|
|
29
|
+
defaultContent: `# CLO Model Service Dockerfile
|
|
30
|
+
FROM python:3.10-slim
|
|
31
|
+
|
|
32
|
+
WORKDIR /app
|
|
33
|
+
|
|
34
|
+
# System dependencies
|
|
35
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
|
36
|
+
curl \\
|
|
37
|
+
build-essential \\
|
|
38
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
39
|
+
|
|
40
|
+
# Install service-specific requirements
|
|
41
|
+
COPY \${servicename:-common}/requirements.txt ./requirements.txt
|
|
42
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
43
|
+
|
|
44
|
+
# Copy source code and scripts
|
|
45
|
+
COPY . .
|
|
46
|
+
RUN chmod +x *.sh common/*.sh 2>/dev/null || true
|
|
47
|
+
|
|
48
|
+
ENV SERVICE_NAME="\${SERVICE_NAME}"
|
|
49
|
+
ENV PORT="\${PORT:-8080}"
|
|
50
|
+
EXPOSE \${PORT:-8080}
|
|
51
|
+
|
|
52
|
+
CMD ["./run.sh"]
|
|
53
|
+
`,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
path: "Jenkinsfile",
|
|
57
|
+
description: "Jenkins CI/CD Pipeline definition",
|
|
58
|
+
defaultContent: `pipeline {
|
|
59
|
+
agent any
|
|
60
|
+
environment {
|
|
61
|
+
SERVICE_NAME = "\${SERVICE_NAME}"
|
|
62
|
+
BRANCH_SLUG = "\${BRANCH_SLUG}"
|
|
63
|
+
DOCKER_IMAGE = "clomodel/\${BRANCH_SLUG}:\${COMMIT_SHA}"
|
|
64
|
+
}
|
|
65
|
+
stages {
|
|
66
|
+
stage('Checkout') {
|
|
67
|
+
steps {
|
|
68
|
+
checkout scm
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
stage('Build & Test') {
|
|
72
|
+
steps {
|
|
73
|
+
sh 'chmod +x init.sh && ./init.sh'
|
|
74
|
+
sh 'pytest tests/ || echo "No tests configured"'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
stage('Docker Build') {
|
|
78
|
+
steps {
|
|
79
|
+
sh "docker build -f Dockerfile_model -t \${DOCKER_IMAGE} ."
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
stage('SonarQube Analysis') {
|
|
83
|
+
steps {
|
|
84
|
+
sh 'sonar-scanner -Dproject.settings=sonar-project.properties'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
`,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
path: "app_env.properties",
|
|
93
|
+
description: "Application properties file",
|
|
94
|
+
defaultContent: `# CLO Model Application Properties
|
|
95
|
+
app.service.name=\${SERVICE_NAME}
|
|
96
|
+
app.service.branch=\${BRANCH_NAME}
|
|
97
|
+
app.service.port=\${PORT:-8080}
|
|
98
|
+
app.service.env=\${ENV:-uat}
|
|
99
|
+
app.log.level=\${LOG_LEVEL:-INFO}
|
|
100
|
+
app.model.version=\${MODEL_VERSION:-1.0.0}
|
|
101
|
+
`,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
path: "get_property_from_env_file.sh",
|
|
105
|
+
description: "Shell helper script to extract properties from .env",
|
|
106
|
+
defaultContent: `#!/usr/bin/env bash
|
|
107
|
+
# Helper script to extract a property value from a .env file
|
|
108
|
+
# Usage: ./get_property_from_env_file.sh <KEY> [ENV_FILE]
|
|
109
|
+
|
|
110
|
+
set -euo pipefail
|
|
111
|
+
|
|
112
|
+
KEY="\${1:-}"
|
|
113
|
+
ENV_FILE="\${2:-.env}"
|
|
114
|
+
|
|
115
|
+
if [ -z "$KEY" ]; then
|
|
116
|
+
echo "Usage: $0 <PROPERTY_KEY> [ENV_FILE]" >&2
|
|
117
|
+
exit 1
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
if [ ! -f "$ENV_FILE" ]; then
|
|
121
|
+
echo "Error: Environment file '$ENV_FILE' not found." >&2
|
|
122
|
+
exit 1
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
# Extract key value ignoring comments and export prefix
|
|
126
|
+
VAL=$(grep -E "^(export[[:space:]]+)?\${KEY}=" "$ENV_FILE" | head -n 1 | cut -d '=' -f2- | tr -d '\\r"' || true)
|
|
127
|
+
echo "$VAL"
|
|
128
|
+
`,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
path: "init.sh",
|
|
132
|
+
description: "Environment initialization script",
|
|
133
|
+
defaultContent: `#!/usr/bin/env bash
|
|
134
|
+
# CLO Model Initialization Script
|
|
135
|
+
set -euo pipefail
|
|
136
|
+
|
|
137
|
+
echo "Initializing \${SERVICE_NAME:-clo-model} for branch \${BRANCH_NAME:-main}..."
|
|
138
|
+
|
|
139
|
+
# Ensure log directory exists
|
|
140
|
+
mkdir -p logs
|
|
141
|
+
|
|
142
|
+
# Validate and load configuration
|
|
143
|
+
if [ -f .env ]; then
|
|
144
|
+
echo "Loading environment from .env..."
|
|
145
|
+
set -a
|
|
146
|
+
source .env
|
|
147
|
+
set +a
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
echo "Initialization complete."
|
|
151
|
+
`,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
path: "run.sh",
|
|
155
|
+
description: "Service startup execution script",
|
|
156
|
+
defaultContent: `#!/usr/bin/env bash
|
|
157
|
+
# CLO Model Service Execution Script
|
|
158
|
+
set -euo pipefail
|
|
159
|
+
|
|
160
|
+
export SERVICE_NAME="\${SERVICE_NAME:-clo-model}"
|
|
161
|
+
export PORT="\${PORT:-8080}"
|
|
162
|
+
export LOG_LEVEL="\${LOG_LEVEL:-INFO}"
|
|
163
|
+
|
|
164
|
+
echo "Starting \${SERVICE_NAME} on port \${PORT}..."
|
|
165
|
+
|
|
166
|
+
# Execute main service entry point
|
|
167
|
+
if [ -f "\${servicename:-.}/main.py" ]; then
|
|
168
|
+
python3 "\${servicename:-.}/main.py" --port "\${PORT}"
|
|
169
|
+
else
|
|
170
|
+
python3 -m http.server "\${PORT}"
|
|
171
|
+
fi
|
|
172
|
+
`,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
path: "sonar-project.properties",
|
|
176
|
+
description: "SonarQube static analysis configuration",
|
|
177
|
+
defaultContent: `# SonarQube Project Configuration
|
|
178
|
+
sonar.projectKey=clomodel-\${SERVICE_NAME:-default}
|
|
179
|
+
sonar.projectName=CLO Model - \${SERVICE_NAME:-default}
|
|
180
|
+
sonar.projectVersion=\${MODEL_VERSION:-1.0.0}
|
|
181
|
+
sonar.sources=.
|
|
182
|
+
sonar.exclusions=**/tests/**,**/logs/**,**/__pycache__/**
|
|
183
|
+
sonar.python.version=3.10
|
|
184
|
+
sonar.sourceEncoding=UTF-8
|
|
185
|
+
`,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
path: "common/run.sh",
|
|
189
|
+
description: "Shared common run script",
|
|
190
|
+
defaultContent: `#!/usr/bin/env bash
|
|
191
|
+
# Shared execution wrapper for CLO Model services
|
|
192
|
+
set -euo pipefail
|
|
193
|
+
|
|
194
|
+
echo "[common/run.sh] Preparing environment for \${SERVICE_NAME}..."
|
|
195
|
+
mkdir -p logs
|
|
196
|
+
|
|
197
|
+
exec ./run.sh "$@"
|
|
198
|
+
`,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
path: "common/var_log.oy",
|
|
202
|
+
description: "Common logger utility module",
|
|
203
|
+
defaultContent: `"""
|
|
204
|
+
Common Logging Utility for CLO Model Services.
|
|
205
|
+
Configures structured log output and log rotation.
|
|
206
|
+
"""
|
|
207
|
+
import os
|
|
208
|
+
import sys
|
|
209
|
+
import logging
|
|
210
|
+
|
|
211
|
+
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
212
|
+
SERVICE_NAME = os.environ.get("SERVICE_NAME", "clo-model")
|
|
213
|
+
|
|
214
|
+
logging.basicConfig(
|
|
215
|
+
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
|
216
|
+
format=f"%(asctime)s [%(levelname)s] [{SERVICE_NAME}] %(name)s: %(message)s",
|
|
217
|
+
handlers=[
|
|
218
|
+
logging.StreamHandler(sys.stdout)
|
|
219
|
+
]
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
logger = logging.getLogger(SERVICE_NAME)
|
|
223
|
+
`,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
path: "{servicename}/requirements.txt",
|
|
227
|
+
description: "Python dependencies (path resolved dynamically from branch .env 'servicename')",
|
|
228
|
+
isDynamic: true,
|
|
229
|
+
dynamicVar: "servicename",
|
|
230
|
+
defaultContent: `# Python dependencies for {servicename}
|
|
231
|
+
flask>=2.3.0
|
|
232
|
+
gunicorn>=20.1.0
|
|
233
|
+
requests>=2.31.0
|
|
234
|
+
numpy>=1.24.0
|
|
235
|
+
pandas>=2.0.0
|
|
236
|
+
scikit-learn>=1.3.0
|
|
237
|
+
pydantic>=2.0.0
|
|
238
|
+
`,
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
export function detectPreset(remoteUrl, repoName) {
|
|
244
|
+
if (!remoteUrl && !repoName)
|
|
245
|
+
return undefined;
|
|
246
|
+
const target = (remoteUrl || repoName || "").toLowerCase();
|
|
247
|
+
for (const preset of REPO_PRESETS) {
|
|
248
|
+
if (Array.isArray(preset.matchRemote)) {
|
|
249
|
+
for (const m of preset.matchRemote) {
|
|
250
|
+
if (target.includes(m.toLowerCase()))
|
|
251
|
+
return preset;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else if (preset.matchRemote instanceof RegExp && preset.matchRemote.test(target)) {
|
|
255
|
+
return preset;
|
|
256
|
+
}
|
|
257
|
+
if (preset.matchName && repoName) {
|
|
258
|
+
const t2 = repoName.toLowerCase();
|
|
259
|
+
if (Array.isArray(preset.matchName)) {
|
|
260
|
+
for (const m of preset.matchName) {
|
|
261
|
+
if (t2.includes(m.toLowerCase()))
|
|
262
|
+
return preset;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else if (preset.matchName instanceof RegExp && preset.matchName.test(t2)) {
|
|
266
|
+
return preset;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type BranchRef = {
|
|
|
21
21
|
};
|
|
22
22
|
export type TargetFile = {
|
|
23
23
|
path: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
isDynamic?: boolean;
|
|
26
|
+
dynamicVar?: string;
|
|
24
27
|
};
|
|
25
28
|
export type TemplateEngineType = "env-reconcile" | "source-of-truth";
|
|
26
29
|
export type TargetTemplate = {
|
|
@@ -34,6 +37,9 @@ export type TargetTemplate = {
|
|
|
34
37
|
raw: string;
|
|
35
38
|
}>;
|
|
36
39
|
isNewFile?: boolean;
|
|
40
|
+
isDynamic?: boolean;
|
|
41
|
+
dynamicVar?: string;
|
|
42
|
+
description?: string;
|
|
37
43
|
};
|
|
38
44
|
export type ManagedProject = {
|
|
39
45
|
id: string;
|
package/dist/stages/apply.js
CHANGED
|
@@ -9,113 +9,136 @@ export const applyChangesStage = async (context, deps) => {
|
|
|
9
9
|
}
|
|
10
10
|
sectionHeader("Applying Changes");
|
|
11
11
|
const results = [];
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return approval?.approved === true;
|
|
15
|
-
});
|
|
16
|
-
// Handle unapproved/error plans first
|
|
12
|
+
// Group all plans by branch
|
|
13
|
+
const allBranchPlans = new Map();
|
|
17
14
|
for (const plan of context.plans) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
const list = allBranchPlans.get(plan.branch.name) || [];
|
|
16
|
+
list.push(plan);
|
|
17
|
+
allBranchPlans.set(plan.branch.name, list);
|
|
18
|
+
}
|
|
19
|
+
// Filter approved plans grouped by branch
|
|
20
|
+
const approvedBranchMap = new Map();
|
|
21
|
+
for (const [branchName, plans] of allBranchPlans.entries()) {
|
|
22
|
+
const approvedPlansForBranch = plans.filter((p) => {
|
|
23
|
+
const approval = context.approvals.find((a) => a.branchName === p.branch.name && a.planHash === p.planHash);
|
|
24
|
+
return approval?.approved === true;
|
|
25
|
+
});
|
|
26
|
+
if (approvedPlansForBranch.length > 0) {
|
|
27
|
+
approvedBranchMap.set(branchName, approvedPlansForBranch);
|
|
23
28
|
}
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
}
|
|
30
|
+
// Record unapproved, error, or unchanged branch results
|
|
31
|
+
for (const [branchName, plans] of allBranchPlans.entries()) {
|
|
32
|
+
if (approvedBranchMap.has(branchName))
|
|
33
|
+
continue; // Handled below
|
|
34
|
+
const firstBranch = plans[0].branch;
|
|
35
|
+
const hasError = plans.some((p) => p.status === "error");
|
|
36
|
+
const hasChanged = plans.some((p) => p.status === "changed");
|
|
37
|
+
if (hasError) {
|
|
38
|
+
const errPlan = plans.find((p) => p.status === "error");
|
|
39
|
+
results.push({ branch: firstBranch, status: "failed", reason: errPlan?.reason ?? "Planning error" });
|
|
26
40
|
}
|
|
27
|
-
else {
|
|
41
|
+
else if (hasChanged) {
|
|
42
|
+
const wasReviewed = context.approvals.some((a) => a.branchName === branchName);
|
|
28
43
|
results.push({
|
|
29
|
-
branch:
|
|
30
|
-
status:
|
|
31
|
-
reason:
|
|
44
|
+
branch: firstBranch,
|
|
45
|
+
status: wasReviewed ? "rejected" : "skipped",
|
|
46
|
+
reason: wasReviewed ? "User rejected" : "Not approved",
|
|
32
47
|
});
|
|
33
48
|
}
|
|
49
|
+
else {
|
|
50
|
+
results.push({ branch: firstBranch, status: "unchanged", reason: "No changes across files" });
|
|
51
|
+
}
|
|
34
52
|
}
|
|
35
|
-
if (
|
|
53
|
+
if (approvedBranchMap.size === 0) {
|
|
36
54
|
statusLine("info", "No approved changes to apply");
|
|
37
55
|
return ok({ ...context, results });
|
|
38
56
|
}
|
|
39
|
-
for (const
|
|
57
|
+
for (const [branchName, plansToApply] of approvedBranchMap.entries()) {
|
|
58
|
+
const branch = plansToApply[0].branch;
|
|
40
59
|
const s = clack.spinner();
|
|
41
|
-
s.start(`Applying ${
|
|
60
|
+
s.start(`Applying ${branchName} (${plansToApply.length} file(s))…`);
|
|
42
61
|
try {
|
|
43
|
-
// Verify ownership before
|
|
62
|
+
// 1. Verify workspace ownership before destructive op
|
|
44
63
|
const ownerCheck = deps.services.workspace.verifyOwnership(context.project);
|
|
45
64
|
if (!ownerCheck.ok) {
|
|
46
|
-
s.stop(chalk.red(`${
|
|
47
|
-
results.push({ branch
|
|
65
|
+
s.stop(chalk.red(`${branchName} — ownership verification failed`));
|
|
66
|
+
results.push({ branch, status: "failed", reason: ownerCheck.error.message });
|
|
48
67
|
continue;
|
|
49
68
|
}
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
const currentShaResult = await deps.services.gitOps.getBranchSha(context.project.managedRepoPath,
|
|
53
|
-
? `${
|
|
54
|
-
:
|
|
69
|
+
// 2. Stale-plan guard
|
|
70
|
+
const baseCommitSha = plansToApply[0].baseCommitSha;
|
|
71
|
+
const currentShaResult = await deps.services.gitOps.getBranchSha(context.project.managedRepoPath, branch.isRemote && branch.remote
|
|
72
|
+
? `${branch.remote}/${branch.name}`
|
|
73
|
+
: branch.name);
|
|
55
74
|
if (!currentShaResult.ok) {
|
|
56
|
-
s.stop(chalk.red(`${
|
|
57
|
-
results.push({ branch
|
|
58
|
-
// Clean up before next branch
|
|
75
|
+
s.stop(chalk.red(`${branchName} — could not verify branch tip`));
|
|
76
|
+
results.push({ branch, status: "failed", reason: "Could not verify branch tip" });
|
|
59
77
|
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
60
78
|
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
|
|
61
79
|
continue;
|
|
62
80
|
}
|
|
63
|
-
if (currentShaResult.value !==
|
|
64
|
-
s.stop(chalk.yellow(`${
|
|
81
|
+
if (currentShaResult.value !== baseCommitSha) {
|
|
82
|
+
s.stop(chalk.yellow(`${branchName} — branch advanced since review (stale plan)`));
|
|
65
83
|
results.push({
|
|
66
|
-
branch
|
|
84
|
+
branch,
|
|
67
85
|
status: "failed",
|
|
68
|
-
reason: `Branch advanced: reviewed at ${
|
|
86
|
+
reason: `Branch advanced: reviewed at ${baseCommitSha.slice(0, 8)}, now at ${currentShaResult.value.slice(0, 8)}. Re-run to replan.`,
|
|
69
87
|
});
|
|
70
88
|
continue;
|
|
71
89
|
}
|
|
72
|
-
//
|
|
90
|
+
// 3. Clean before checkout
|
|
73
91
|
await deps.services.gitOps.resetHard(context.project.managedRepoPath);
|
|
74
92
|
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode);
|
|
75
|
-
//
|
|
76
|
-
const checkoutResult = await deps.services.gitOps.checkoutBranch(context.project.managedRepoPath,
|
|
93
|
+
// 4. Checkout branch
|
|
94
|
+
const checkoutResult = await deps.services.gitOps.checkoutBranch(context.project.managedRepoPath, branch.name);
|
|
77
95
|
if (!checkoutResult.ok) {
|
|
78
|
-
s.stop(chalk.red(`${
|
|
79
|
-
results.push({ branch
|
|
96
|
+
s.stop(chalk.red(`${branchName} — checkout failed`));
|
|
97
|
+
results.push({ branch, status: "failed", reason: checkoutResult.error.message });
|
|
80
98
|
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
81
99
|
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
|
|
82
100
|
continue;
|
|
83
101
|
}
|
|
84
|
-
//
|
|
102
|
+
// 5. Clean after checkout
|
|
85
103
|
await deps.services.gitOps.resetHard(context.project.managedRepoPath);
|
|
86
104
|
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode);
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
105
|
+
// 6. Verify before-content hash and stage each file
|
|
106
|
+
let branchFileError;
|
|
107
|
+
for (const plan of plansToApply) {
|
|
108
|
+
const currentFileResult = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, branch, plan.path);
|
|
109
|
+
const currentContent = currentFileResult.ok ? currentFileResult.value : "";
|
|
110
|
+
const currentHash = sha256(currentContent);
|
|
111
|
+
if (currentHash !== plan.beforeContentHash) {
|
|
112
|
+
branchFileError = `File '${plan.path}' changed since review`;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
const writeResult = await deps.services.gitOps.writeAndStage(context.project.managedRepoPath, plan.path, plan.afterContent);
|
|
116
|
+
if (!writeResult.ok) {
|
|
117
|
+
branchFileError = `Write failed for '${plan.path}': ${writeResult.error.message}`;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (branchFileError) {
|
|
122
|
+
s.stop(chalk.yellow(`${branchName} — ${branchFileError}`));
|
|
93
123
|
results.push({
|
|
94
|
-
branch
|
|
124
|
+
branch,
|
|
95
125
|
status: "failed",
|
|
96
|
-
reason:
|
|
126
|
+
reason: `${branchFileError}. Re-run to replan.`,
|
|
97
127
|
});
|
|
98
128
|
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
99
129
|
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
|
|
100
130
|
continue;
|
|
101
131
|
}
|
|
102
|
-
//
|
|
103
|
-
const writeResult = await deps.services.gitOps.writeAndStage(context.project.managedRepoPath, plan.path, plan.afterContent);
|
|
104
|
-
if (!writeResult.ok) {
|
|
105
|
-
s.stop(chalk.red(`${plan.branch.name} — write failed`));
|
|
106
|
-
results.push({ branch: plan.branch, status: "failed", reason: writeResult.error.message });
|
|
107
|
-
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
// ── Verify only the intended path is staged ─────────────────────
|
|
132
|
+
// 7. Verify staged files match expected paths
|
|
111
133
|
const stagedResult = await deps.services.gitOps.getStagedFiles(context.project.managedRepoPath);
|
|
112
134
|
if (stagedResult.ok) {
|
|
113
135
|
const staged = stagedResult.value;
|
|
114
|
-
const
|
|
136
|
+
const expectedSet = new Set(plansToApply.map((p) => p.path));
|
|
137
|
+
const unexpected = staged.filter((f) => !expectedSet.has(f));
|
|
115
138
|
if (unexpected.length > 0) {
|
|
116
|
-
s.stop(chalk.red(`${
|
|
139
|
+
s.stop(chalk.red(`${branchName} — unexpected staged files`));
|
|
117
140
|
results.push({
|
|
118
|
-
branch
|
|
141
|
+
branch,
|
|
119
142
|
status: "failed",
|
|
120
143
|
reason: `Unexpected staged files: ${unexpected.join(", ")}. Aborting commit for safety.`,
|
|
121
144
|
});
|
|
@@ -123,32 +146,30 @@ export const applyChangesStage = async (context, deps) => {
|
|
|
123
146
|
continue;
|
|
124
147
|
}
|
|
125
148
|
if (staged.length === 0) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
results.push({ branch: plan.branch, status: "unchanged", reason: "Content identical after write" });
|
|
149
|
+
s.stop(chalk.gray(`${branchName} — no staged changes (already matches)`));
|
|
150
|
+
results.push({ branch, status: "unchanged", reason: "Content identical after write" });
|
|
129
151
|
continue;
|
|
130
152
|
}
|
|
131
153
|
}
|
|
132
|
-
//
|
|
154
|
+
// 8. Commit all staged files in one atomic commit
|
|
133
155
|
const commitMsg = deps.config.git.commitMessage;
|
|
134
156
|
const commitResult = await deps.services.gitOps.commit(context.project.managedRepoPath, commitMsg);
|
|
135
157
|
if (!commitResult.ok) {
|
|
136
|
-
s.stop(chalk.red(`${
|
|
137
|
-
results.push({ branch
|
|
158
|
+
s.stop(chalk.red(`${branchName} — commit failed`));
|
|
159
|
+
results.push({ branch, status: "failed", reason: commitResult.error.message });
|
|
138
160
|
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
139
161
|
continue;
|
|
140
162
|
}
|
|
141
|
-
s.stop(chalk.green(`${
|
|
163
|
+
s.stop(chalk.green(`${branchName} — committed ${commitResult.value.slice(0, 8)} (${plansToApply.length} file(s))`));
|
|
142
164
|
results.push({
|
|
143
|
-
branch
|
|
165
|
+
branch,
|
|
144
166
|
status: "committed",
|
|
145
167
|
commitSha: commitResult.value,
|
|
146
168
|
});
|
|
147
169
|
}
|
|
148
170
|
catch (e) {
|
|
149
|
-
s.stop(chalk.red(`${
|
|
150
|
-
results.push({ branch
|
|
151
|
-
// Clean up managed checkout before next branch
|
|
171
|
+
s.stop(chalk.red(`${branchName} — unexpected error`));
|
|
172
|
+
results.push({ branch, status: "failed", reason: e.message });
|
|
152
173
|
await deps.services.gitOps.resetHard(context.project.managedRepoPath).catch(() => { });
|
|
153
174
|
await deps.services.gitOps.clean(context.project.managedRepoPath, deps.config.git.cleanMode).catch(() => { });
|
|
154
175
|
}
|