@cruxy/cli 1.3.0 → 1.4.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/dist/utils/git.js CHANGED
@@ -41,6 +41,79 @@ function runGitAsync(args, cwd) {
41
41
  export function countChanges(status) {
42
42
  return status.split("\n").filter((line) => line.trim() !== "").length;
43
43
  }
44
+ /** Porcelain XY code → the status this reports. */
45
+ function statusFromCode(x, y) {
46
+ if (x === "?" || y === "?")
47
+ return "untracked";
48
+ // A `U` on either side, or the AA/DD pairs, are unmerged paths.
49
+ if (x === "U" ||
50
+ y === "U" ||
51
+ (x === "A" && y === "A") ||
52
+ (x === "D" && y === "D")) {
53
+ return "conflicted";
54
+ }
55
+ if (x === "R")
56
+ return "renamed";
57
+ if (x === "A")
58
+ return "added";
59
+ if (x === "D" || y === "D")
60
+ return "deleted";
61
+ return "modified";
62
+ }
63
+ /**
64
+ * Parse `git status --porcelain` into one entry per path.
65
+ *
66
+ * The format is `XY <path>`, with a rename written `R old -> new`. Renames
67
+ * take the NEW path because that is where the content lives now and what a
68
+ * follow-up diff or edit would name; the old one is not shown, which is a real
69
+ * (small) loss of information accepted to keep one row per change.
70
+ *
71
+ * Paths containing spaces are handled because the split is positional (columns
72
+ * 0-1 are the code, 3 onward is the path) rather than whitespace-delimited.
73
+ * Quoted paths — git quotes non-ASCII unless `core.quotePath=false` — are left
74
+ * exactly as git wrote them rather than half-unescaped into something that
75
+ * looks like a real path but is not.
76
+ */
77
+ export function parsePorcelain(text) {
78
+ const out = [];
79
+ for (const line of text.split("\n")) {
80
+ if (line.trim() === "")
81
+ continue;
82
+ const x = line[0] ?? " ";
83
+ const y = line[1] ?? " ";
84
+ let path = line.slice(3);
85
+ const status = statusFromCode(x, y);
86
+ if (status === "renamed") {
87
+ const arrow = path.indexOf(" -> ");
88
+ if (arrow >= 0)
89
+ path = path.slice(arrow + 4);
90
+ }
91
+ out.push({ path, status, added: null, removed: null, approximate: false });
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * Parse `git diff --numstat` into per-path line counts.
97
+ *
98
+ * A binary file is reported as `-\t-\t<path>`, which becomes `null` on both
99
+ * sides — the honest answer, and the one {@link GitFileChange} already models.
100
+ */
101
+ export function parseNumstat(text) {
102
+ const out = new Map();
103
+ for (const line of text.split("\n")) {
104
+ if (line.trim() === "")
105
+ continue;
106
+ const [addedRaw, removedRaw, ...rest] = line.split("\t");
107
+ const path = rest.join("\t");
108
+ if (path === "")
109
+ continue;
110
+ out.set(path, {
111
+ added: addedRaw === "-" ? null : Number(addedRaw),
112
+ removed: removedRaw === "-" ? null : Number(removedRaw),
113
+ });
114
+ }
115
+ return out;
116
+ }
44
117
  /** Shared shaping so the sync and async paths cannot disagree. */
45
118
  function toGitInfo(branch, status) {
46
119
  const changed = countChanges(status);
@@ -87,3 +160,43 @@ export async function getGitInfoAsync(cwd) {
87
160
  return null;
88
161
  return toGitInfo(branch, status);
89
162
  }
163
+ /**
164
+ * {@link getGitInfoAsync} plus the per-path detail the Git view needs (P7
165
+ * track 4): the same branch and count, and a `files` list.
166
+ *
167
+ * A SEPARATE ENTRY POINT rather than an option on the old one, because the
168
+ * extra `--numstat` is a third subprocess and the rail must not start paying
169
+ * for a list it has one line to show. Consumers pick the probe that matches
170
+ * what they render, and `files` being absent on the cheap one is what makes
171
+ * that choice visible in the type.
172
+ *
173
+ * The numstat is diffed against HEAD so staged and unstaged work both count —
174
+ * the question a Git view answers is "what has this session done to my files",
175
+ * and whether a change happens to be staged is not part of it. Untracked files
176
+ * appear in the porcelain but in no diff, so their counts stay `null` rather
177
+ * than being invented from a file read.
178
+ */
179
+ export async function getGitTreeAsync(cwd) {
180
+ const branch = await runGitAsync(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
181
+ if (branch === null)
182
+ return null;
183
+ const status = await runGitAsync(["status", "--porcelain"], cwd);
184
+ if (status === null)
185
+ return null;
186
+ const info = toGitInfo(branch, status);
187
+ const files = parsePorcelain(status);
188
+ // Best-effort: a repo with no commits yet has no HEAD to diff against, and
189
+ // that must degrade to "counts unknown", not to "not a repo".
190
+ const numstat = await runGitAsync(["diff", "--numstat", "HEAD"], cwd);
191
+ if (numstat !== null) {
192
+ const counts = parseNumstat(numstat);
193
+ for (const file of files) {
194
+ const c = counts.get(file.path);
195
+ if (c) {
196
+ file.added = c.added;
197
+ file.removed = c.removed;
198
+ }
199
+ }
200
+ }
201
+ return { ...info, files };
202
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {