@samaksh/git-worker 0.2.3 → 0.3.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.
@@ -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
+ }
@@ -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;
@@ -9,113 +9,136 @@ export const applyChangesStage = async (context, deps) => {
9
9
  }
10
10
  sectionHeader("Applying Changes");
11
11
  const results = [];
12
- const approvedPlans = context.plans.filter((p) => {
13
- const approval = context.approvals.find((a) => a.branchName === p.branch.name);
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 approval = context.approvals.find((a) => a.branchName === plan.branch.name);
19
- if (approval?.approved)
20
- continue; // Will be handled below
21
- if (plan.status === "unchanged") {
22
- results.push({ branch: plan.branch, status: "unchanged", reason: "No changes" });
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
- else if (plan.status === "error") {
25
- results.push({ branch: plan.branch, status: "failed", reason: plan.reason });
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: plan.branch,
30
- status: approval ? "rejected" : "skipped",
31
- reason: approval ? "User rejected" : "Not reviewed",
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 (approvedPlans.length === 0) {
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 plan of approvedPlans) {
57
+ for (const [branchName, plansToApply] of approvedBranchMap.entries()) {
58
+ const branch = plansToApply[0].branch;
40
59
  const s = clack.spinner();
41
- s.start(`Applying ${plan.branch.name}…`);
60
+ s.start(`Applying ${branchName} (${plansToApply.length} file(s))…`);
42
61
  try {
43
- // Verify ownership before any destructive op
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(`${plan.branch.name} — ownership verification failed`));
47
- results.push({ branch: plan.branch, status: "failed", reason: ownerCheck.error.message });
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
- // ── Stale-plan guard ────────────────────────────────────────────
51
- // Fetch latest SHA and verify it matches the reviewed plan
52
- const currentShaResult = await deps.services.gitOps.getBranchSha(context.project.managedRepoPath, plan.branch.isRemote && plan.branch.remote
53
- ? `${plan.branch.remote}/${plan.branch.name}`
54
- : plan.branch.name);
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(`${plan.branch.name} — could not verify branch tip`));
57
- results.push({ branch: plan.branch, status: "failed", reason: "Could not verify branch tip" });
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 !== plan.baseCommitSha) {
64
- s.stop(chalk.yellow(`${plan.branch.name} — branch advanced since review (stale plan)`));
81
+ if (currentShaResult.value !== baseCommitSha) {
82
+ s.stop(chalk.yellow(`${branchName} — branch advanced since review (stale plan)`));
65
83
  results.push({
66
- branch: plan.branch,
84
+ branch,
67
85
  status: "failed",
68
- reason: `Branch advanced: reviewed at ${plan.baseCommitSha.slice(0, 8)}, now at ${currentShaResult.value.slice(0, 8)}. Re-run to replan.`,
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
- // ── Clean before checkout ───────────────────────────────────────
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
- // ── Checkout branch ─────────────────────────────────────────────
76
- const checkoutResult = await deps.services.gitOps.checkoutBranch(context.project.managedRepoPath, plan.branch.name);
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(`${plan.branch.name} — checkout failed`));
79
- results.push({ branch: plan.branch, status: "failed", reason: checkoutResult.error.message });
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
- // ── Clean after checkout ─────────────────────────────────────────
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
- // ── Verify before-content hash ──────────────────────────────────
88
- const currentFileResult = await deps.services.git.readFileFromBranch(context.project.managedRepoPath, plan.branch, plan.path);
89
- const currentContent = currentFileResult.ok ? currentFileResult.value : "";
90
- const currentHash = sha256(currentContent);
91
- if (currentHash !== plan.beforeContentHash) {
92
- s.stop(chalk.yellow(`${plan.branch.name} — file content changed since review`));
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: plan.branch,
124
+ branch,
95
125
  status: "failed",
96
- reason: "File content changed since review. Re-run to replan.",
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
- // ── Write and stage ─────────────────────────────────────────────
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 unexpected = staged.filter((f) => f !== plan.path);
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(`${plan.branch.name} — unexpected staged files`));
139
+ s.stop(chalk.red(`${branchName} — unexpected staged files`));
117
140
  results.push({
118
- branch: plan.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
- // No-op commit — already matches
127
- s.stop(chalk.gray(`${plan.branch.name} — no staged changes (already matches)`));
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
- // ── Commit ──────────────────────────────────────────────────────
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(`${plan.branch.name} — commit failed`));
137
- results.push({ branch: plan.branch, status: "failed", reason: commitResult.error.message });
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(`${plan.branch.name} — committed ${commitResult.value.slice(0, 8)}`));
163
+ s.stop(chalk.green(`${branchName} — committed ${commitResult.value.slice(0, 8)} (${plansToApply.length} file(s))`));
142
164
  results.push({
143
- branch: plan.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(`${plan.branch.name} — unexpected error`));
150
- results.push({ branch: plan.branch, status: "failed", reason: e.message });
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
  }