karajan-code 2.10.1 → 2.11.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.
@@ -26,9 +26,19 @@ function printSessionPlanner(planner) {
26
26
  }
27
27
  }
28
28
 
29
- function printSessionSonar(sonar) {
29
+ export function printSessionSonar(sonar) {
30
30
  if (!sonar) return;
31
- const gateLabel = sonar.gateStatus === "OK" ? ANSI.green : ANSI.red;
31
+ // Three buckets so the banner reads correctly:
32
+ // - OK \u2192 green (real pass).
33
+ // - SKIPPED / PENDING \u2192 gray (informational; sonar
34
+ // wasn't gated against, this is NOT a fail). Pre-2026-05-07 N4
35
+ // dogfooding these were rendered red, which made a clean run
36
+ // look like a sonar failure during the live demo.
37
+ // - everything else (ERROR, WARN, ...) \u2192 red (real fail).
38
+ let gateLabel;
39
+ if (sonar.gateStatus === "OK") gateLabel = ANSI.green;
40
+ else if (sonar.gateStatus === "SKIPPED" || sonar.gateStatus === "PENDING") gateLabel = ANSI.gray;
41
+ else gateLabel = ANSI.red;
32
42
  console.log(` ${ANSI.dim}\ud83d\udd0d Sonar: ${gateLabel}${sonar.gateStatus}${ANSI.reset}${ANSI.dim} (${sonar.openIssues ?? 0} issues)${ANSI.reset}`);
33
43
  if (typeof sonar.issuesInitial === "number" || typeof sonar.issuesResolved === "number") {
34
44
  const issuesInitial = sonar.issuesInitial ?? sonar.openIssues ?? 0;
package/src/utils/git.js CHANGED
@@ -65,6 +65,42 @@ export async function revParse(ref) {
65
65
  return runGit(["rev-parse", ref]);
66
66
  }
67
67
 
68
+ /**
69
+ * List commits in the range `fromSha..toRef`. Used by the post-loop
70
+ * journal builder to enumerate every commit produced during a `kj run`
71
+ * — both the coder's own commits AND the post-loop scaffold commit —
72
+ * so the user-visible summary doesn't lose track of them. Pre-fix the
73
+ * summary only listed `gitResult.commits` (post-loop only), which made
74
+ * `kj run "..."` runs look like the coder hadn't committed.
75
+ *
76
+ * Returns oldest-first so the list reads in execution order.
77
+ *
78
+ * @param {string|null|undefined} fromSha The SHA right BEFORE the run started
79
+ * (typically `session.session_start_sha`). Empty/missing → returns [].
80
+ * @param {string} [toRef="HEAD"]
81
+ * @returns {Promise<Array<{hash: string, message: string}>>}
82
+ */
83
+ export async function listCommitsBetween(fromSha, toRef = "HEAD") {
84
+ if (!fromSha) return [];
85
+ const result = await run(
86
+ "git",
87
+ ["log", "--reverse", "--format=%H%x09%s", `${fromSha}..${toRef}`],
88
+ ).catch(() => null);
89
+ if (!result || result.exitCode !== 0) return [];
90
+ return String(result.stdout || "")
91
+ .split("\n")
92
+ .map((line) => line.trimEnd())
93
+ .filter(Boolean)
94
+ .map((line) => {
95
+ const tabIdx = line.indexOf("\t");
96
+ // Defensive: if git emitted no tab (shouldn't happen with our format)
97
+ // treat the whole line as the hash and leave the message empty rather
98
+ // than throw — caller can still render the row.
99
+ if (tabIdx === -1) return { hash: line, message: "" };
100
+ return { hash: line.slice(0, tabIdx), message: line.slice(tabIdx + 1) };
101
+ });
102
+ }
103
+
68
104
  export async function syncBaseBranch({ baseBranch, autoRebase }) {
69
105
  const local = await revParse(baseBranch);
70
106
  const remote = await revParse(`origin/${baseBranch}`);
@@ -109,11 +145,79 @@ export async function hasChanges() {
109
145
  return status.length > 0;
110
146
  }
111
147
 
148
+ /**
149
+ * Return the relative paths of every uncommitted change (modified,
150
+ * untracked, staged, etc.) in the working tree. Each path is the
151
+ * filename as `git status --porcelain` reports it (after the 2-char
152
+ * status code + space). Returns an empty array when the tree is clean.
153
+ *
154
+ * Used by `finalizeGitAutomation` to decide whether the changes
155
+ * pending at the end of a pipeline are exclusively Karajan
156
+ * scaffolding (and thus deserve a `chore:` commit message instead
157
+ * of inheriting the user task's `feat:`).
158
+ *
159
+ * NOTE: bypasses `runGit()` because that helper trims stdout, which
160
+ * eats the leading space of porcelain lines whose first status char
161
+ * is " " (e.g. " M .gitignore" → "M .gitignore"). The leading space
162
+ * is structural here — without it the slice(3) below would crop one
163
+ * character off the first path.
164
+ */
165
+ export async function listPendingPaths() {
166
+ const res = await run("git", ["status", "--porcelain"]);
167
+ if (res.exitCode !== 0) {
168
+ throw new Error(`git status --porcelain failed: ${res.stderr || res.stdout}`);
169
+ }
170
+ const stdout = res.stdout;
171
+ if (!stdout) return [];
172
+ const lines = stdout.split("\n").map((l) => l.replace(/\r$/, "")).filter(Boolean);
173
+ const out = [];
174
+ for (const line of lines) {
175
+ // Each line is "XY <path>" (XY = 2-char status, space, path).
176
+ // Renames look like "R old -> new"; we want the new path.
177
+ const after = line.slice(3);
178
+ const arrow = after.indexOf(" -> ");
179
+ out.push(arrow !== -1 ? after.slice(arrow + 4) : after);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ // Locale-tolerant detection of "git commit refused because the working
185
+ // tree was already clean by the time we ran it". Observed in N4
186
+ // dogfooding (2026-05-07) — `hasChanges()` reported true after `add -A`
187
+ // but `git commit` then failed with the Spanish message "nada para hacer
188
+ // commit, el árbol de trabajo está limpio". The race window is real:
189
+ // post-loop journal writes can finish between hasChanges and commit, and
190
+ // some files come back from `add -A` with no diff vs HEAD even though
191
+ // they show up in `git status --porcelain`. We treat any of those as
192
+ // "no commit needed" rather than escalating to Solomon.
193
+ const NOTHING_TO_COMMIT_PATTERNS = [
194
+ /nothing to commit/i,
195
+ /nada para hacer commit/i, // git in es_ES locale
196
+ /no hay nada para confirmar/i, // git in es_ES alt phrasing
197
+ /aucune modification ajout[ée]e au commit/i, // fr_FR
198
+ /nichts zu committen/i, // de_DE
199
+ ];
200
+ function isNothingToCommit(message) {
201
+ const m = String(message || "");
202
+ return NOTHING_TO_COMMIT_PATTERNS.some((re) => re.test(m));
203
+ }
204
+
112
205
  export async function commitAll(message) {
113
206
  await runGit(["add", "-A"]);
114
207
  const changed = await hasChanges();
115
208
  if (!changed) return { committed: false };
116
- await runGit(["commit", "-m", message]);
209
+ try {
210
+ await runGit(["commit", "-m", message]);
211
+ } catch (err) {
212
+ // `git status --porcelain` and `git commit` disagreed about whether
213
+ // there was anything to commit. Don't escalate — the only outcome
214
+ // either way is "no new commit was created", which the caller
215
+ // already handles via committed=false.
216
+ if (isNothingToCommit(err?.message)) {
217
+ return { committed: false };
218
+ }
219
+ throw err;
220
+ }
117
221
  const raw = await runGit(["log", "-1", "--pretty=format:%H%x1f%s"]);
118
222
  const [hash, commitMessage] = raw.split("\x1f");
119
223
  return { committed: true, commit: { hash, message: commitMessage } };