kodevu 0.1.53 → 0.1.54

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/README.md CHANGED
@@ -32,6 +32,7 @@ npx kodevu [target] [options]
32
32
  - `--reviewer, -r`: `codex`, `gemini`, `copilot`, `openai`, or `auto` (default: `auto`).
33
33
  - `--rev, -v`: A specific revision or commit hash to review.
34
34
  - `--last, -n`: Number of latest revisions to review (default: 1). Use negative values (e.g., `-3`) to review only the 3rd commit from the top.
35
+ - `--uncommitted, -u`: Review current uncommitted changes in the target working tree.
35
36
  - `--lang, -l`: Output language (e.g., `zh`, `en`, `auto`).
36
37
  - `--prompt, -p`: Additional instructions for the reviewer. Use `@file.txt` to read from a file.
37
38
  - `--output, -o`: Report output directory (default: `~/.kodevu`).
@@ -47,6 +48,9 @@ npx kodevu [target] [options]
47
48
  > [!IMPORTANT]
48
49
  > `--rev` and `--last` are mutually exclusive. Specifying both will result in an error.
49
50
 
51
+ > [!IMPORTANT]
52
+ > `--uncommitted` is mutually exclusive with `--rev` and `--last`.
53
+
50
54
  ### Environment Variables
51
55
 
52
56
  You can set these in your shell to change default behavior without typing flags every time:
@@ -81,6 +85,11 @@ Review a **specific commit** hash:
81
85
  npx kodevu . --rev abc1234
82
86
  ```
83
87
 
88
+ Review **current uncommitted changes** (Git/SVN working copy):
89
+ ```bash
90
+ npx kodevu . --uncommitted
91
+ ```
92
+
84
93
  ### Options & Formatting
85
94
 
86
95
  Review using **custom instructions** from a file:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodevu",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Poll SVN revisions or Git commits, send each change diff to a reviewer CLI, and write configurable review reports.",
package/src/config.js CHANGED
@@ -23,6 +23,7 @@ const defaultConfig = {
23
23
  outputFormats: ["markdown"],
24
24
  rev: "",
25
25
  last: 0,
26
+ uncommitted: false,
26
27
  openaiApiKey: "",
27
28
  openaiBaseUrl: "https://api.openai.com/v1",
28
29
  openaiModel: "gpt-5-mini",
@@ -124,6 +125,7 @@ export function parseCliArgs(argv) {
124
125
  prompt: "",
125
126
  rev: "",
126
127
  last: "",
128
+ uncommitted: false,
127
129
  outputDir: "",
128
130
  outputFormats: "",
129
131
  openaiApiKey: "",
@@ -190,6 +192,11 @@ export function parseCliArgs(argv) {
190
192
  continue;
191
193
  }
192
194
 
195
+ if (value === "--uncommitted" || value === "-u") {
196
+ args.uncommitted = true;
197
+ continue;
198
+ }
199
+
193
200
  if (value === "--output" || value === "-o") {
194
201
  if (!hasNextValue) throw new Error(`Missing value for ${value}`);
195
202
  args.outputDir = nextValue;
@@ -268,6 +275,7 @@ export async function resolveConfig(cliArgs = {}) {
268
275
  "lang",
269
276
  "rev",
270
277
  "last",
278
+ "uncommitted",
271
279
  "outputDir",
272
280
  "outputFormats",
273
281
  "openaiApiKey",
@@ -285,6 +293,10 @@ export async function resolveConfig(cliArgs = {}) {
285
293
  throw new Error("Parameters --rev and --last are mutually exclusive. Please specify only one.");
286
294
  }
287
295
 
296
+ if (cliArgs.uncommitted && (cliArgs.rev || cliArgs.last)) {
297
+ throw new Error("Parameter --uncommitted is mutually exclusive with --rev and --last.");
298
+ }
299
+
288
300
  if (!config.target) {
289
301
  config.target = process.cwd();
290
302
  }
@@ -321,6 +333,7 @@ export async function resolveConfig(cliArgs = {}) {
321
333
  config.maxRevisionsPerRun = Number(config.maxRevisionsPerRun);
322
334
  config.commandTimeoutMs = Number(config.commandTimeoutMs);
323
335
  config.last = Number(config.last);
336
+ config.uncommitted = Boolean(config.uncommitted);
324
337
  config.outputFormats = normalizeOutputFormats(config.outputFormats);
325
338
  config.openaiApiKey = String(config.openaiApiKey || "").trim();
326
339
  config.openaiBaseUrl = String(config.openaiBaseUrl || defaultConfig.openaiBaseUrl).trim().replace(/\/+$/, "");
@@ -328,7 +341,7 @@ export async function resolveConfig(cliArgs = {}) {
328
341
  config.openaiOrganization = String(config.openaiOrganization || "").trim();
329
342
  config.openaiProject = String(config.openaiProject || "").trim();
330
343
 
331
- if (!config.rev && (isNaN(config.last) || config.last === 0)) {
344
+ if (!config.uncommitted && !config.rev && (isNaN(config.last) || config.last === 0)) {
332
345
  config.last = 1;
333
346
  }
334
347
 
@@ -352,6 +365,7 @@ Options:
352
365
  --lang, -l Output language (e.g. zh, en, auto)
353
366
  --rev, -v Review specific revision(s), hashes, branches or ranges (comma-separated)
354
367
  --last, -n Review the latest N revisions; use negative (-N) to review only the Nth-from-last revision (default: 1)
368
+ --uncommitted, -u Review current uncommitted changes (mutually exclusive with --rev and --last)
355
369
  --output, -o Output directory (default: ~/.kodevu)
356
370
  --format, -f Output formats (markdown, json, comma-separated)
357
371
  --openai-api-key API key used when reviewer=openai
package/src/git-client.js CHANGED
@@ -181,6 +181,49 @@ function parseNameStatus(stdout) {
181
181
  return changedPaths;
182
182
  }
183
183
 
184
+ function parseNameStatusLines(stdout) {
185
+ return stdout
186
+ .split(/\r?\n/)
187
+ .map((line) => line.trim())
188
+ .filter(Boolean)
189
+ .map((line) => line.split("\t"))
190
+ .map((parts) => {
191
+ const status = (parts[0] || "M").trim();
192
+ const action = status[0] || "M";
193
+
194
+ if ((action === "R" || action === "C") && parts.length >= 3) {
195
+ return {
196
+ action,
197
+ relativePath: parts[2],
198
+ previousPath: parts[1] || null
199
+ };
200
+ }
201
+
202
+ return {
203
+ action,
204
+ relativePath: parts[1] || "",
205
+ previousPath: null
206
+ };
207
+ })
208
+ .filter((item) => item.relativePath);
209
+ }
210
+
211
+ function mergeChangedPaths(...groups) {
212
+ const merged = [];
213
+ const seen = new Set();
214
+
215
+ for (const group of groups) {
216
+ for (const item of group) {
217
+ const key = `${item.action}|${item.relativePath}|${item.previousPath || ""}`;
218
+ if (seen.has(key)) continue;
219
+ seen.add(key);
220
+ merged.push(item);
221
+ }
222
+ }
223
+
224
+ return merged;
225
+ }
226
+
184
227
  export async function getCommitDetails(config, targetInfo, commitHash) {
185
228
  const metaResult = await runGit(
186
229
  config,
@@ -214,3 +257,63 @@ export async function getCommitDetails(config, targetInfo, commitHash) {
214
257
  changedPaths: parseNameStatus(changedFilesResult.stdout)
215
258
  };
216
259
  }
260
+
261
+ export async function getUncommittedDiff(config, targetInfo) {
262
+ const unstaged = await runGit(
263
+ config,
264
+ [
265
+ "diff",
266
+ "--find-renames",
267
+ "--find-copies",
268
+ "--no-ext-diff",
269
+ ...buildPathArgs(targetInfo)
270
+ ],
271
+ { cwd: targetInfo.repoRootPath, trim: false }
272
+ );
273
+ const staged = await runGit(
274
+ config,
275
+ [
276
+ "diff",
277
+ "--cached",
278
+ "--find-renames",
279
+ "--find-copies",
280
+ "--no-ext-diff",
281
+ ...buildPathArgs(targetInfo)
282
+ ],
283
+ { cwd: targetInfo.repoRootPath, trim: false }
284
+ );
285
+
286
+ const sections = [];
287
+ if (unstaged.stdout.trim()) {
288
+ sections.push("# Unstaged changes", unstaged.stdout.trimEnd());
289
+ }
290
+ if (staged.stdout.trim()) {
291
+ sections.push("# Staged changes", staged.stdout.trimEnd());
292
+ }
293
+
294
+ return sections.join("\n\n");
295
+ }
296
+
297
+ export async function getUncommittedDetails(config, targetInfo) {
298
+ const unstagedFiles = await runGit(
299
+ config,
300
+ ["diff", "--name-status", "-M", "-C", ...buildPathArgs(targetInfo)],
301
+ { cwd: targetInfo.repoRootPath, trim: false }
302
+ );
303
+ const stagedFiles = await runGit(
304
+ config,
305
+ ["diff", "--cached", "--name-status", "-M", "-C", ...buildPathArgs(targetInfo)],
306
+ { cwd: targetInfo.repoRootPath, trim: false }
307
+ );
308
+
309
+ const unstagedChanged = parseNameStatusLines(unstagedFiles.stdout);
310
+ const stagedChanged = parseNameStatusLines(stagedFiles.stdout);
311
+
312
+ return {
313
+ commitHash: "UNCOMMITTED",
314
+ author: "working-tree",
315
+ date: new Date().toISOString(),
316
+ message: "Uncommitted changes (staged + unstaged).",
317
+ changedPaths: mergeChangedPaths(unstagedChanged, stagedChanged)
318
+ };
319
+ }
@@ -155,7 +155,9 @@ export async function runReviewCycle(config) {
155
155
 
156
156
  let changeIdsToReview = [];
157
157
 
158
- if (config.rev) {
158
+ if (config.uncommitted) {
159
+ changeIdsToReview = ["UNCOMMITTED"];
160
+ } else if (config.rev) {
159
161
  changeIdsToReview = await backend.resolveChangeIds(config, targetInfo, config.rev);
160
162
  } else if (config.last < 0) {
161
163
  const candidates = await backend.getLatestChangeIds(config, targetInfo, Math.abs(config.last));
@@ -169,7 +171,12 @@ export async function runReviewCycle(config) {
169
171
  return;
170
172
  }
171
173
 
172
- logger.info(`Reviewing ${backend.displayName} ${backend.changeName}s ${formatChangeList(backend, changeIdsToReview)}`);
174
+ const isUncommittedBatch =
175
+ changeIdsToReview.length === 1 && changeIdsToReview[0] === "UNCOMMITTED";
176
+ const batchSummary = isUncommittedBatch
177
+ ? `Reviewing ${backend.displayName} uncommitted changes`
178
+ : `Reviewing ${backend.displayName} ${backend.changeName}s ${formatChangeList(backend, changeIdsToReview)}`;
179
+ logger.info(batchSummary);
173
180
  const progress = createProgressReporter(`${backend.displayName} ${backend.changeName} batch`);
174
181
  progress.update(0, `0/${changeIdsToReview.length} completed`);
175
182
 
package/src/svn-client.js CHANGED
@@ -128,6 +128,24 @@ function toRelativePath(targetRepoPath, repoPath) {
128
128
  return repoPath.slice(targetRepoPath.length).replace(/^\/+/, "");
129
129
  }
130
130
 
131
+ function parseSvnStatus(statusText) {
132
+ return statusText
133
+ .split(/\r?\n/)
134
+ .map((line) => line.replace(/\r$/, ""))
135
+ .filter(Boolean)
136
+ .map((line) => {
137
+ const action = (line[0] || " ").trim();
138
+ const relativePath = line.length > 8 ? line.slice(8).trim() : "";
139
+ return {
140
+ action,
141
+ relativePath,
142
+ previousPath: null
143
+ };
144
+ })
145
+ .filter((item) => item.relativePath)
146
+ .filter((item) => ["A", "D", "M", "R"].includes(item.action));
147
+ }
148
+
131
149
  export async function getRevisionDetails(config, targetInfo, revision) {
132
150
  const result = await runCommand(
133
151
  SVN_COMMAND,
@@ -165,3 +183,37 @@ export async function getRevisionDetails(config, targetInfo, revision) {
165
183
  changedPaths
166
184
  };
167
185
  }
186
+
187
+ export async function getUncommittedDiff(config, targetInfo) {
188
+ if (!targetInfo.workingCopyPath) {
189
+ throw new Error("SVN --uncommitted requires a working copy path target.");
190
+ }
191
+
192
+ const result = await runCommand(
193
+ SVN_COMMAND,
194
+ ["diff", "--git", "--internal-diff", "--ignore-properties", config.target],
195
+ { encoding: COMMAND_ENCODING, trim: false, debug: config.debug }
196
+ );
197
+
198
+ return result.stdout;
199
+ }
200
+
201
+ export async function getUncommittedDetails(config, targetInfo) {
202
+ if (!targetInfo.workingCopyPath) {
203
+ throw new Error("SVN --uncommitted requires a working copy path target.");
204
+ }
205
+
206
+ const statusResult = await runCommand(
207
+ SVN_COMMAND,
208
+ ["status", config.target],
209
+ { encoding: COMMAND_ENCODING, trim: false, debug: config.debug }
210
+ );
211
+
212
+ return {
213
+ revision: "UNCOMMITTED",
214
+ author: "working-copy",
215
+ date: new Date().toISOString(),
216
+ message: "Uncommitted changes in working copy.",
217
+ changedPaths: parseSvnStatus(statusResult.stdout)
218
+ };
219
+ }
package/src/vcs-client.js CHANGED
@@ -13,9 +13,13 @@ function createSvnBackend() {
13
13
  displayName: "SVN",
14
14
  changeName: "revision",
15
15
  formatChangeId(revision) {
16
+ if (revision === "UNCOMMITTED") return "uncommitted";
16
17
  return `r${revision}`;
17
18
  },
18
19
  getReportFileName(revision) {
20
+ if (revision === "UNCOMMITTED") {
21
+ return `${getTimestampPrefix()}-svn-uncommitted.md`;
22
+ }
19
23
  return `${getTimestampPrefix()}-svn-r${revision}.md`;
20
24
  },
21
25
 
@@ -33,9 +37,25 @@ function createSvnBackend() {
33
37
  return await svnClient.getLatestRevisionIds(config, targetInfo, limit);
34
38
  },
35
39
  async getChangeDiff(config, targetInfo, revision) {
40
+ if (revision === "UNCOMMITTED") {
41
+ return await svnClient.getUncommittedDiff(config, targetInfo);
42
+ }
36
43
  return await svnClient.getRevisionDiff(config, revision);
37
44
  },
38
45
  async getChangeDetails(config, targetInfo, revision) {
46
+ if (revision === "UNCOMMITTED") {
47
+ const details = await svnClient.getUncommittedDetails(config, targetInfo);
48
+
49
+ return {
50
+ id: "UNCOMMITTED",
51
+ displayId: "uncommitted",
52
+ author: details.author,
53
+ date: details.date,
54
+ message: details.message,
55
+ changedPaths: details.changedPaths
56
+ };
57
+ }
58
+
39
59
  const details = await svnClient.getRevisionDetails(config, targetInfo, revision);
40
60
 
41
61
  return {
@@ -56,9 +76,13 @@ function createGitBackend() {
56
76
  displayName: "Git",
57
77
  changeName: "commit",
58
78
  formatChangeId(commitHash) {
79
+ if (commitHash === "UNCOMMITTED") return "uncommitted";
59
80
  return commitHash.slice(0, 12);
60
81
  },
61
82
  getReportFileName(commitHash) {
83
+ if (commitHash === "UNCOMMITTED") {
84
+ return `${getTimestampPrefix()}-git-uncommitted.md`;
85
+ }
62
86
  return `${getTimestampPrefix()}-git-${commitHash.slice(0, 12)}.md`;
63
87
  },
64
88
 
@@ -82,9 +106,25 @@ function createGitBackend() {
82
106
  return await gitClient.getLatestCommitIds(config, targetInfo, limit);
83
107
  },
84
108
  async getChangeDiff(config, targetInfo, commitHash) {
109
+ if (commitHash === "UNCOMMITTED") {
110
+ return await gitClient.getUncommittedDiff(config, targetInfo);
111
+ }
85
112
  return await gitClient.getCommitDiff(config, targetInfo, commitHash);
86
113
  },
87
114
  async getChangeDetails(config, targetInfo, commitHash) {
115
+ if (commitHash === "UNCOMMITTED") {
116
+ const details = await gitClient.getUncommittedDetails(config, targetInfo);
117
+
118
+ return {
119
+ id: "UNCOMMITTED",
120
+ displayId: "uncommitted",
121
+ author: details.author,
122
+ date: details.date,
123
+ message: details.message,
124
+ changedPaths: details.changedPaths
125
+ };
126
+ }
127
+
88
128
  const details = await gitClient.getCommitDetails(config, targetInfo, commitHash);
89
129
 
90
130
  return {