@gavin-hu/my-pi-agent 0.14.0 → 0.15.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.
Files changed (229) hide show
  1. package/README.md +54 -34
  2. package/README.zh-CN.md +44 -28
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +126 -37
  57. package/extensions/file-browse/client.ts +98 -33
  58. package/extensions/file-browse/config.ts +24 -9
  59. package/extensions/file-browse/files.ts +183 -48
  60. package/extensions/file-browse/fuzzy.ts +65 -11
  61. package/extensions/file-browse/git.ts +37 -47
  62. package/extensions/file-browse/html.ts +97 -171
  63. package/extensions/file-browse/live-client.ts +158 -0
  64. package/extensions/file-browse/live.ts +222 -0
  65. package/extensions/file-browse/markdown.ts +110 -92
  66. package/extensions/file-browse/raw-html.ts +478 -0
  67. package/extensions/file-browse/router.ts +65 -78
  68. package/extensions/file-browse/server.ts +75 -59
  69. package/extensions/file-browse/styles.ts +124 -0
  70. package/extensions/file-browse/urls.ts +80 -0
  71. package/extensions/goal/README.md +103 -47
  72. package/extensions/goal/clarify-parse.ts +4 -9
  73. package/extensions/goal/clarify.ts +27 -4
  74. package/extensions/goal/commands.ts +24 -11
  75. package/extensions/goal/format.ts +41 -10
  76. package/extensions/goal/index.ts +39 -16
  77. package/extensions/goal/plan-route.ts +51 -0
  78. package/extensions/goal/schema.ts +6 -23
  79. package/extensions/goal/state.ts +39 -57
  80. package/extensions/goal/tools.ts +26 -20
  81. package/extensions/goal/types.ts +11 -1
  82. package/extensions/job/README.md +20 -6
  83. package/extensions/job/format.ts +18 -68
  84. package/extensions/job/index.ts +26 -41
  85. package/extensions/job/log-writer.ts +208 -0
  86. package/extensions/job/logs.ts +3 -2
  87. package/extensions/job/process.ts +6 -99
  88. package/extensions/job/runtime.ts +42 -181
  89. package/extensions/job/schema.ts +1 -4
  90. package/extensions/job/tools.ts +1 -15
  91. package/extensions/job/tui.ts +117 -307
  92. package/extensions/job/ui.ts +7 -33
  93. package/extensions/memory/README.md +1 -1
  94. package/extensions/memory/errors.ts +5 -6
  95. package/extensions/memory/tui.ts +14 -24
  96. package/extensions/plan/README.md +94 -18
  97. package/extensions/plan/commands.ts +9 -5
  98. package/extensions/plan/format.ts +87 -0
  99. package/extensions/plan/index.ts +31 -15
  100. package/extensions/plan/plans.ts +2 -3
  101. package/extensions/plan/policy.ts +0 -6
  102. package/extensions/plan/runtime.ts +4 -2
  103. package/extensions/plan/steps.ts +37 -8
  104. package/extensions/plan/todos.ts +44 -3
  105. package/extensions/plan/tools/enter.ts +94 -0
  106. package/extensions/plan/tools/exit.ts +296 -0
  107. package/extensions/plan/tools/nested.ts +21 -0
  108. package/extensions/plan/tools/steps.ts +136 -0
  109. package/extensions/plan/tools/todo-seed.ts +76 -0
  110. package/extensions/plan/tools/write.ts +127 -0
  111. package/extensions/plan/tools.ts +18 -703
  112. package/extensions/plan/web-client.ts +6 -22
  113. package/extensions/plan/web-page.ts +19 -30
  114. package/extensions/plan/web-router.ts +32 -79
  115. package/extensions/plan/web.ts +21 -42
  116. package/extensions/question/answers.ts +3 -8
  117. package/extensions/question/tui.ts +6 -6
  118. package/extensions/rewind/README.md +95 -22
  119. package/extensions/rewind/checklist.ts +192 -0
  120. package/extensions/rewind/commands.ts +8 -0
  121. package/extensions/rewind/config.ts +3 -11
  122. package/extensions/rewind/format.ts +220 -2
  123. package/extensions/rewind/git.ts +101 -21
  124. package/extensions/rewind/hunk-picker.ts +114 -0
  125. package/extensions/rewind/patch.ts +88 -0
  126. package/extensions/rewind/path-picker.ts +67 -100
  127. package/extensions/rewind/paths.ts +93 -19
  128. package/extensions/rewind/restore.ts +53 -10
  129. package/extensions/rewind/review-tui.ts +320 -0
  130. package/extensions/rewind/review.ts +397 -0
  131. package/extensions/rewind/rewind-tui.ts +4 -3
  132. package/extensions/rewind/runtime.ts +94 -17
  133. package/extensions/rewind/snapshot.ts +13 -4
  134. package/extensions/subagent/README.md +61 -6
  135. package/extensions/subagent/background-tui.ts +208 -0
  136. package/extensions/subagent/background.ts +470 -0
  137. package/extensions/subagent/commands.ts +80 -0
  138. package/extensions/subagent/config.ts +4 -0
  139. package/extensions/subagent/format.ts +24 -1
  140. package/extensions/subagent/index.ts +91 -2
  141. package/extensions/subagent/render.ts +13 -0
  142. package/extensions/subagent/run.ts +17 -16
  143. package/extensions/subagent/schema.ts +12 -0
  144. package/extensions/subagent/types.ts +11 -0
  145. package/extensions/subagent/ui.ts +50 -0
  146. package/extensions/todo/README.md +18 -13
  147. package/extensions/todo/format.ts +3 -2
  148. package/extensions/todo/runtime.ts +3 -2
  149. package/extensions/todo/schema.ts +8 -84
  150. package/extensions/todo/state.ts +5 -46
  151. package/extensions/todo/tools.ts +2 -1
  152. package/extensions/todo/types.ts +6 -15
  153. package/extensions/watch/README.md +14 -6
  154. package/extensions/watch/child.ts +150 -0
  155. package/extensions/watch/format.ts +8 -48
  156. package/extensions/watch/index.ts +42 -58
  157. package/extensions/watch/runtime.ts +49 -128
  158. package/extensions/watch/schema.ts +1 -4
  159. package/extensions/watch/tools.ts +1 -11
  160. package/extensions/watch/tui.ts +103 -273
  161. package/extensions/watch/ui.ts +5 -32
  162. package/extensions/web-access/README.md +1 -1
  163. package/extensions/web-access/fetch/page.ts +1 -1
  164. package/extensions/web-access/fetch/tool.ts +6 -2
  165. package/extensions/web-access/search/search.ts +2 -5
  166. package/extensions/wechat/README.md +1 -1
  167. package/extensions/wechat/client.ts +23 -0
  168. package/extensions/wechat/commands.ts +57 -42
  169. package/extensions/wechat/deliver.ts +5 -9
  170. package/extensions/wechat/errors.ts +3 -6
  171. package/extensions/wechat/inbound.ts +2 -5
  172. package/extensions/wechat/index.ts +2 -8
  173. package/extensions/wechat/outbound.ts +2 -4
  174. package/extensions/wechat/peer-worker.ts +3 -8
  175. package/extensions/wechat/poll.ts +10 -9
  176. package/extensions/wechat/presence.ts +35 -62
  177. package/extensions/wechat/reply-pipeline.ts +3 -1
  178. package/extensions/wechat/status-screen.ts +1 -1
  179. package/extensions/wechat/timer.ts +57 -0
  180. package/extensions/worktree/config.ts +8 -20
  181. package/extensions/worktree/enter.ts +7 -12
  182. package/extensions/worktree/git.ts +38 -69
  183. package/extensions/worktree/index.ts +5 -4
  184. package/extensions/worktree/registry.ts +7 -7
  185. package/extensions/worktree/root-tools.ts +2 -2
  186. package/extensions/worktree/status.ts +15 -9
  187. package/lib/README.md +23 -8
  188. package/lib/config.ts +50 -3
  189. package/lib/errors.ts +12 -0
  190. package/lib/format.ts +50 -0
  191. package/lib/git/read.ts +28 -0
  192. package/lib/goal-state.ts +23 -8
  193. package/lib/http/headers.ts +74 -0
  194. package/lib/http/index.ts +13 -0
  195. package/lib/http/response.ts +46 -0
  196. package/lib/http/router.ts +52 -0
  197. package/lib/http/server.ts +109 -0
  198. package/lib/lines.ts +77 -0
  199. package/lib/pdf.ts +82 -0
  200. package/lib/plan-entry.ts +71 -0
  201. package/lib/plan-state.ts +34 -0
  202. package/lib/pool.ts +62 -0
  203. package/lib/report.ts +99 -0
  204. package/lib/requirements.ts +25 -19
  205. package/lib/spawn.ts +170 -0
  206. package/lib/todo-state.ts +110 -23
  207. package/lib/tool-names.ts +7 -0
  208. package/lib/tool-result.ts +33 -0
  209. package/lib/tui/README.md +68 -3
  210. package/lib/tui/chrome.ts +28 -4
  211. package/lib/tui/columns.ts +6 -1
  212. package/lib/tui/confirm.ts +65 -0
  213. package/lib/tui/dock-screen.ts +418 -0
  214. package/lib/tui/glyphs.ts +2 -1
  215. package/lib/tui/indent.ts +1 -1
  216. package/lib/tui/index.ts +2 -0
  217. package/lib/tui/rows.ts +21 -0
  218. package/lib/tui/status-controller.ts +35 -1
  219. package/lib/tui/tool.ts +59 -1
  220. package/lib/web/components.ts +20 -1
  221. package/lib/web/markup.ts +32 -0
  222. package/lib/web/runtime.ts +50 -0
  223. package/package.json +22 -5
  224. package/skills/init/SKILL.md +165 -0
  225. package/skills/init/references/agents-md-quality.md +103 -0
  226. package/skills/init/references/interview-questions.md +81 -0
  227. package/skills/refactor/SKILL.md +141 -0
  228. package/extensions/watch/process.ts +0 -63
  229. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -5,21 +5,21 @@
5
5
  * The screen is the timeline's sibling — same dock frame, same cursor, same
6
6
  * width-safe row grammar — so a partial restore reads as one more step in
7
7
  * `/rewind` rather than a second product. Rows are selected by default, which
8
- * makes `Enter` here reproduce the whole-tree restore exactly.
8
+ * makes `Enter` here reproduce the whole-tree restore exactly. The frame, the
9
+ * cursor, and the toggle keys come from {@link ChecklistScreen}.
9
10
  *
10
11
  * The component owns only the selection: paths in, a chosen subset out through
11
12
  * `onClose`, and the restore itself stays behind the confirmation that follows.
12
13
  */
13
14
 
14
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
- import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
16
- import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../lib/tui/cursor.ts";
17
- import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
16
+ import { sanitizeLine } from "../../lib/format.ts";
17
+ import { screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
18
18
  import { clip } from "../../lib/tui/columns.ts";
19
19
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
20
20
  import { checkbox, listRow } from "../../lib/tui/rows.ts";
21
- import { titleRule } from "../../lib/tui/rules.ts";
22
21
  import { paint } from "../../lib/tui/tokens.ts";
22
+ import { ChecklistScreen } from "./checklist.ts";
23
23
  import { baseName, expandGroup, groupPaths, SELECTION_MARKS, type ChangedPath, type PathGroup } from "./paths.ts";
24
24
 
25
25
  export interface PathPickerOptions {
@@ -32,119 +32,79 @@ export interface PathPickerOptions {
32
32
  viewportRows?: ViewportRowsSource;
33
33
  }
34
34
 
35
- /** Rows the screen shows when the terminal height is unknown. */
36
- const SCREEN_DEFAULT_ITEMS = 12;
37
- /** Header, summary, blanks, and footer rows around the list. */
38
- const SCREEN_CHROME_ROWS = 6;
39
-
40
35
  /** One rendered row: a directory header or a file under one. */
41
36
  type Row = { kind: "group"; group: PathGroup } | { kind: "file"; path: ChangedPath; dir: string };
42
37
 
43
38
  /** The checklist of changed paths. */
44
- export class PathPickerComponent implements Component {
45
- private readonly cursor: ListCursor;
39
+ export class PathPickerComponent extends ChecklistScreen<Row> {
40
+ private readonly view: PathPickerOptions;
46
41
  private readonly groups: PathGroup[];
47
- private readonly rows: Row[];
48
- private readonly chosen = new Set<string>();
49
-
50
- constructor(private readonly options: PathPickerOptions) {
51
- this.cursor = new ListCursor(options.requestRender);
52
- this.groups = groupPaths(options.paths);
53
- // Every path starts selected, so confirming without touching anything means
54
- // "restore it all" — the behavior `/rewind` had before the checklist existed.
55
- for (const group of this.groups) for (const path of expandGroup(group)) this.chosen.add(path);
56
- this.rows = this.groups.flatMap((group): Row[] => [
42
+ private readonly flat: readonly Row[];
43
+
44
+ constructor(view: PathPickerOptions) {
45
+ super({
46
+ title: "Restore paths",
47
+ theme: view.theme,
48
+ requestRender: view.requestRender,
49
+ viewportRows: view.viewportRows,
50
+ });
51
+ this.view = view;
52
+ this.groups = groupPaths(view.paths);
53
+ this.flat = this.groups.flatMap((group): Row[] => [
57
54
  { kind: "group", group },
58
55
  ...group.paths.map((path): Row => ({ kind: "file", path, dir: group.dir })),
59
56
  ]);
60
57
  }
61
58
 
62
- /** Whether every file under `group` is selected. */
63
- private groupOn(group: PathGroup): boolean {
64
- return group.paths.every((path) => this.chosen.has(path.path));
59
+ protected rows(): readonly Row[] {
60
+ return this.flat;
65
61
  }
66
62
 
67
- /** Toggle every file under `group` to `on`. */
68
- private setGroup(group: PathGroup, on: boolean): void {
69
- for (const path of expandGroup(group)) {
70
- if (on) this.chosen.add(path);
71
- else this.chosen.delete(path);
72
- }
63
+ /** A group header's key is never selected: it toggles its files instead. */
64
+ protected keyOf(row: Row): string {
65
+ return row.kind === "group" ? row.group.dir : row.path.path;
73
66
  }
74
67
 
75
- /** Apply a key press. Exposed for the tests that drive the screen directly. */
76
- handleInput(data: string): void {
77
- if (isCloseKey(data)) {
78
- this.options.onClose(undefined);
79
- return;
80
- }
81
- if (navigateList(data, this.cursor, this.rows.length)) return;
82
- if (data === " ") {
83
- const row = this.rows[this.cursor.selected];
84
- if (row?.kind === "group") this.setGroup(row.group, !this.groupOn(row.group));
85
- else if (row) {
86
- if (this.chosen.has(row.path.path)) this.chosen.delete(row.path.path);
87
- else this.chosen.add(row.path.path);
88
- }
89
- this.options.requestRender();
90
- return;
91
- }
92
- if (data === "a") {
93
- for (const path of this.options.paths) this.chosen.add(path.path);
94
- this.options.requestRender();
95
- return;
96
- }
97
- if (data === "n") {
98
- this.chosen.clear();
99
- this.options.requestRender();
100
- return;
101
- }
102
- if (matchesKey(data, Key.enter)) this.options.onClose([...this.chosen]);
68
+ /**
69
+ * Every path starts selected, so confirming without touching anything means
70
+ * "restore it all" — the behavior `/rewind` had before the checklist existed.
71
+ */
72
+ protected initialSelection(): readonly string[] {
73
+ return this.groups.flatMap(expandGroup);
103
74
  }
104
75
 
105
- /** Wheel scrolling in fullscreen moves the cursor; regular mode leaves the wheel to the terminal. */
106
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
107
- return scrollList(event, this.cursor, this.rows.length);
76
+ protected selectableKeys(): readonly string[] {
77
+ return this.view.paths.map((path) => path.path);
108
78
  }
109
79
 
110
- dispose(): void {}
111
-
112
- invalidate(): void {}
113
-
114
- // The shared dock frame, like every other list screen.
115
- render(width: number): string[] {
116
- const w = Math.max(1, width);
117
- const header = titleRule(this.theme, w, "Restore paths");
118
- const cursor = this.cursor;
119
- cursor.visible = fitRows(this.options.viewportRows, this.rows.length, SCREEN_CHROME_ROWS, SCREEN_DEFAULT_ITEMS);
120
- const { start, end } = cursor.window(this.rows.length);
121
-
122
- const body: string[] = [];
123
- for (let i = start; i < end; i++) body.push(this.rowLine(w, this.rows[i], i === cursor.selected));
124
-
125
- const total = this.options.paths.length;
126
- const selected = this.chosen.size;
127
- return dockListFrame({
128
- header,
129
- summary: clip(
130
- ` ${paint(this.theme, "meta", `${selected} of ${total} selected${SEPARATORS.item}Space toggles a file or a directory`)}`,
131
- w,
132
- ),
133
- body,
134
- range: cursor.clipped(this.rows.length)
135
- ? formatRange(this.theme, w, { start, end, total: this.rows.length })
136
- : undefined,
137
- detail: this.detail(w),
138
- hint: this.hint(w),
139
- });
80
+ protected onEnter(selection: string[]): void {
81
+ this.view.onClose(selection);
82
+ }
83
+
84
+ protected onCancel(): void {
85
+ this.view.onClose(undefined);
140
86
  }
141
87
 
142
- private get theme(): Theme {
143
- return this.options.theme;
88
+ /** Space on a directory header toggles every file under it. */
89
+ protected override toggle(row: Row): void {
90
+ if (row.kind === "file") {
91
+ super.toggle(row);
92
+ return;
93
+ }
94
+ const on = !this.groupOn(row.group);
95
+ for (const path of expandGroup(row.group)) {
96
+ if (on) this.chosen.add(path);
97
+ else this.chosen.delete(path);
98
+ }
99
+ }
100
+
101
+ /** Whether every file under `group` is selected. */
102
+ private groupOn(group: PathGroup): boolean {
103
+ return group.paths.every((path) => this.chosen.has(path.path));
144
104
  }
145
105
 
146
106
  /** One row: a directory header with its count, or a file with its change mark. */
147
- private rowLine(width: number, row: Row, selected: boolean): string {
107
+ protected rowLine(row: Row, width: number, selected: boolean): string {
148
108
  const tone = selected ? "accent" : row.kind === "group" ? "meta" : "label";
149
109
  if (row.kind === "group") {
150
110
  const count = row.group.paths.length;
@@ -170,10 +130,15 @@ export class PathPickerComponent implements Component {
170
130
  });
171
131
  }
172
132
 
133
+ protected summaryText(): string {
134
+ const total = this.view.paths.length;
135
+ return `${this.chosen.size} of ${total} selected${SEPARATORS.item}Space toggles a file or a directory`;
136
+ }
137
+
173
138
  /** What the focused row will do, in words. */
174
- private detail(width: number): string[] | undefined {
175
- const row = this.rows[this.cursor.selected];
176
- if (!row) return undefined;
139
+ protected detailLines(width: number): string[] {
140
+ const row = this.focusedRow;
141
+ if (!row) return [];
177
142
  const text =
178
143
  row.kind === "group"
179
144
  ? `Space selects or clears all ${row.group.paths.length} file${row.group.paths.length === 1 ? "" : "s"} in this directory.`
@@ -181,12 +146,14 @@ export class PathPickerComponent implements Component {
181
146
  ? "A — created after the snapshot; restoring removes it."
182
147
  : row.path.change === "deleted"
183
148
  ? "D — deleted since the snapshot; restoring brings it back."
184
- : "M — modified since the snapshot; restoring rewrites it.";
149
+ : row.path.change === "renamed"
150
+ ? `R — renamed from ${sanitizeLine(row.path.from)}; restoring brings the old path back and removes this one.`
151
+ : "M — modified since the snapshot; restoring rewrites it.";
185
152
  return [clip(` ${paint(this.theme, "meta", text)}`, width)];
186
153
  }
187
154
 
188
155
  /** The key-hint row, with the primary action in the accent role. */
189
- private hint(width: number): string {
156
+ protected hint(width: number): string {
190
157
  return screenHint(this.theme, width, [
191
158
  "Esc cancel",
192
159
  { text: "Enter restore", color: "accent" },
@@ -6,27 +6,36 @@
6
6
  * cannot disagree about what changed. Pure: parsing, grouping, marks, and the
7
7
  * counts `RestoreSummary` reports.
8
8
  *
9
- * A rename arrives as `R100\told\tnew`. It is expanded into two entries — the old
10
- * path as deleted and the new one as added — because that is exactly what the
11
- * restore does to each: selecting the old path brings it back, selecting the new
12
- * one removes it. Both rows appear in the checklist rather than one opaque one.
9
+ * A rename arrives as `R100\told\tnew` and stays one entry: `path` is the
10
+ * destination, `from` the path it had in the snapshot, and the mark is `R`.
11
+ * Keeping both sides on one entry is what makes a single selection atomic — the
12
+ * restore recreates `from` and deletes `path`, so neither side can be left
13
+ * behind. `entryPaths` is the one place that names both.
13
14
  */
14
15
 
16
+ /** The single-path statuses, which never carry a source path. */
17
+ type SimpleChange = "modified" | "deleted" | "added";
18
+
15
19
  /** What a restore does to one path. */
16
- export type PathChange = "modified" | "deleted" | "added";
20
+ export type PathChange = SimpleChange | "renamed";
17
21
 
18
- /** One path the snapshot differs on, and how. */
19
- export interface ChangedPath {
20
- /** Repository-relative path, as git reports it. */
22
+ /** Fields every changed path carries. */
23
+ interface ChangedPathBase {
24
+ /** Repository-relative path, as git reports it; a rename's destination. */
21
25
  path: string;
22
- change: PathChange;
23
26
  }
24
27
 
28
+ /** One path the snapshot differs on, and how. A rename always names its source. */
29
+ export type ChangedPath =
30
+ | (ChangedPathBase & { change: SimpleChange })
31
+ | (ChangedPathBase & { change: "renamed"; from: string });
32
+
25
33
  /** The trailing mark for each change in the checklist. */
26
34
  export const SELECTION_MARKS: Readonly<Record<PathChange, string>> = {
27
35
  modified: "M",
28
36
  deleted: "D",
29
37
  added: "A",
38
+ renamed: "R",
30
39
  };
31
40
 
32
41
  /** One directory's files, for the checklist's grouping. */
@@ -38,7 +47,7 @@ export interface PathGroup {
38
47
  }
39
48
 
40
49
  /** The status letter → change kind, for the single-path statuses. */
41
- function changeOf(letter: string): PathChange | undefined {
50
+ function changeOf(letter: string): SimpleChange | undefined {
42
51
  switch (letter) {
43
52
  case "M":
44
53
  case "T": // a type change (file ↔ symlink) rewrites the path
@@ -53,10 +62,10 @@ function changeOf(letter: string): PathChange | undefined {
53
62
  }
54
63
 
55
64
  /**
56
- * Parse `git diff --name-status` output. A rename expands to both of its paths
57
- * (as described in the module comment) and a copy to its destination only — the
58
- * source is untouched, so it is not a changed path. An unknown status letter is
59
- * skipped rather than guessed at.
65
+ * Parse `git diff --name-status` output. A rename keeps both of its paths on
66
+ * one entry and a copy becomes its destination only — the source is untouched,
67
+ * so it is not a changed path. An unknown status letter is skipped rather than
68
+ * guessed at.
60
69
  */
61
70
  export function parseNameStatus(stdout: string): ChangedPath[] {
62
71
  const changed: ChangedPath[] = [];
@@ -67,8 +76,8 @@ export function parseNameStatus(stdout: string): ChangedPath[] {
67
76
  const letter = status[0] ?? "";
68
77
  if (letter === "R" || letter === "C") {
69
78
  if (!second) continue;
70
- if (letter === "R") changed.push({ path: first, change: "deleted" });
71
- changed.push({ path: second, change: "added" });
79
+ if (letter === "R") changed.push({ path: second, change: "renamed", from: first });
80
+ else changed.push({ path: second, change: "added" });
72
81
  continue;
73
82
  }
74
83
  const kind = changeOf(letter);
@@ -113,9 +122,19 @@ export function expandGroup(group: PathGroup): string[] {
113
122
  }
114
123
 
115
124
  /**
116
- * The counts the plan and the summary report: `changed` is modified plus deleted
117
- * (files rewritten from the snapshot), and `removed` is the paths created after
118
- * it, which the restore deletes.
125
+ * Every path an entry spans: both sides of a rename, otherwise just its path.
126
+ * A restore and a diff request both use this, so one selection covers the move.
127
+ */
128
+ export function entryPaths(entry: ChangedPath): string[] {
129
+ return entry.change === "renamed" ? [entry.from, entry.path] : [entry.path];
130
+ }
131
+
132
+ /**
133
+ * The counts the plan and the summary report: `changed` is every path rewritten
134
+ * from the snapshot (modified, deleted, or a rename's source), and `removed` is
135
+ * the paths created after it, which the restore deletes. A rename counts once:
136
+ * it is one file changed, and its destination is removed as part of the move
137
+ * rather than counted a second time.
119
138
  */
120
139
  export function summarize(paths: readonly ChangedPath[]): { changed: number; removed: number } {
121
140
  let changed = 0;
@@ -126,3 +145,58 @@ export function summarize(paths: readonly ChangedPath[]): { changed: number; rem
126
145
  }
127
146
  return { changed, removed };
128
147
  }
148
+
149
+ /** Line counts for one path, or nulls when git reports a binary change. */
150
+ export interface PathStat {
151
+ /** Added lines, or null when git reports `-` (binary). */
152
+ added: number | null;
153
+ /** Removed lines, or null when git reports `-` (binary). */
154
+ removed: number | null;
155
+ }
156
+
157
+ /** A changed path with the line counts the review list shows. */
158
+ export type ReviewEntry = ChangedPath & {
159
+ added: number | null;
160
+ removed: number | null;
161
+ };
162
+
163
+ /**
164
+ * Parse `git diff --numstat` output into a path -> line-count map.
165
+ *
166
+ * Counts are `-` for a binary file, which becomes null so the screen can say
167
+ * `bin` instead of inventing a zero. A malformed line is skipped rather than
168
+ * guessed at, and the path is the rest of the line so a path containing a tab
169
+ * survives the split.
170
+ */
171
+ export function parseNumStat(stdout: string): Map<string, PathStat> {
172
+ const stats = new Map<string, PathStat>();
173
+ for (const line of stdout.split("\n")) {
174
+ if (line.trim() === "") continue;
175
+ const [addedRaw, removedRaw, ...rest] = line.split("\t");
176
+ const path = rest.join("\t");
177
+ if (addedRaw === undefined || removedRaw === undefined || path === "") continue;
178
+ const added = addedRaw === "-" ? null : Number.parseInt(addedRaw, 10);
179
+ const removed = removedRaw === "-" ? null : Number.parseInt(removedRaw, 10);
180
+ if (added !== null && !Number.isFinite(added)) continue;
181
+ if (removed !== null && !Number.isFinite(removed)) continue;
182
+ stats.set(path, { added, removed });
183
+ }
184
+ return stats;
185
+ }
186
+
187
+ /**
188
+ * Attach line counts to changed paths and sort them for a stable screen order.
189
+ * Line counts are read with `--no-renames`, so a rename's `+n` lives on its
190
+ * destination and its `−n` on its source.
191
+ */
192
+ export function toReviewEntries(paths: readonly ChangedPath[], stats: ReadonlyMap<string, PathStat>): ReviewEntry[] {
193
+ const countsOf = (entry: ChangedPath): PathStat => {
194
+ const destination = stats.get(entry.path);
195
+ const source = entry.change === "renamed" ? stats.get(entry.from) : undefined;
196
+ return {
197
+ added: destination?.added ?? null,
198
+ removed: (source ?? destination)?.removed ?? null,
199
+ };
200
+ };
201
+ return [...paths].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({ ...entry, ...countsOf(entry) }));
202
+ }
@@ -11,10 +11,11 @@
11
11
  * pathspec, so the selected paths are checked out of the snapshot instead, and
12
12
  * only the selected additions are deleted. Everything else is left exactly as it
13
13
  * is, and the same temporary index is used, so the real index stays untouched
14
- * either way.
14
+ * either way. A rename is one entry whose selection checks out its source and
15
+ * deletes its destination, so the pair cannot be split.
15
16
  */
16
17
 
17
- import { lstatSync, rmSync } from "node:fs";
18
+ import { lstatSync, rmSync, statSync } from "node:fs";
18
19
  import { resolve, sep } from "node:path";
19
20
  import {
20
21
  busyGitState,
@@ -25,7 +26,7 @@ import {
25
26
  treeFromWorkingTree,
26
27
  type RunGit,
27
28
  } from "./git.ts";
28
- import { summarize, type ChangedPath } from "./paths.ts";
29
+ import { entryPaths, summarize, type ChangedPath } from "./paths.ts";
29
30
  import type { Snapshot, RestoreSummary } from "./types.ts";
30
31
 
31
32
  export interface RestoreDeps {
@@ -52,6 +53,8 @@ export interface RestorePlan {
52
53
  removed: number;
53
54
  /** Every changed path, with how the restore treats it. */
54
55
  paths: ChangedPath[];
56
+ /** Tree id of the staged working tree the comparison used. */
57
+ currentTree: string;
55
58
  }
56
59
 
57
60
  export type PlanResult = { ok: true; plan: RestorePlan } | { ok: false; reason: string };
@@ -65,12 +68,13 @@ export async function planRestore(deps: RestoreDeps, input: RestoreInput): Promi
65
68
  const cur = await stagedTree(deps, input);
66
69
  const paths = await changedPaths(deps, input, cur);
67
70
  const diff = await diffStat(deps.runGit, input.root, input.target.commit, cur, selectionOf(paths));
68
- return { ok: true, plan: { diff, ...summarize(paths), paths } };
71
+ return { ok: true, plan: { diff, ...summarize(paths), paths, currentTree: cur } };
69
72
  }
70
73
 
71
74
  /** The selection narrowed to paths the plan actually found, or undefined for all. */
72
75
  function selectionOf(paths: readonly ChangedPath[]): string[] | undefined {
73
- return paths.length > 0 ? paths.map((entry) => entry.path) : undefined;
76
+ const all = paths.flatMap(entryPaths);
77
+ return all.length > 0 ? all : undefined;
74
78
  }
75
79
 
76
80
  /**
@@ -82,12 +86,47 @@ async function stagedTree(deps: RestoreDeps, input: RestoreInput): Promise<strin
82
86
  return treeFromWorkingTree(deps.runGit, input.root, input.indexFile, input.target.includeUntracked);
83
87
  }
84
88
 
85
- /** Every changed path, narrowed to the caller's selection when one is given. */
89
+ /**
90
+ * Every changed path, narrowed to the caller's selection when one is given.
91
+ *
92
+ * The diff is never limited with a pathspec: git detects a rename only when both
93
+ * of its paths are in scope, so a narrowed diff would turn a selected rename
94
+ * back into an addition and lose the side that has to be recreated.
95
+ */
86
96
  async function changedPaths(deps: RestoreDeps, input: RestoreInput, cur: string): Promise<ChangedPath[]> {
87
97
  const all = await nameStatus(deps.runGit, input.root, input.target.commit, cur);
88
98
  if (!input.paths) return all;
89
99
  const selected = new Set(input.paths);
90
- return all.filter((entry) => selected.has(entry.path));
100
+ return all.filter((entry) =>
101
+ entry.change === "renamed" ? selected.has(entry.path) || selected.has(entry.from) : selected.has(entry.path),
102
+ );
103
+ }
104
+
105
+ /** Paths checked out of the snapshot: everything except a path the snapshot lacks. */
106
+ function rewrittenPaths(paths: readonly ChangedPath[]): string[] {
107
+ return paths.flatMap((entry) => {
108
+ if (entry.change === "added") return [];
109
+ return entry.change === "renamed" ? [entry.from] : [entry.path];
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Whether two working-tree paths name the same file.
115
+ *
116
+ * A case-only rename on a case-insensitive filesystem resolves both names to one
117
+ * file, so deleting the destination would delete the source the checkout just
118
+ * restored. Comparing device and inode is cross-platform and needs no assumption
119
+ * about the filesystem's case sensitivity.
120
+ */
121
+ function sameFile(root: string, first: string, second: string): boolean {
122
+ try {
123
+ const a = statSync(resolve(root, first));
124
+ const b = statSync(resolve(root, second));
125
+ return a.dev === b.dev && a.ino === b.ino;
126
+ } catch {
127
+ // A missing path cannot be the same file, and deleting it is a no-op.
128
+ return false;
129
+ }
91
130
  }
92
131
 
93
132
  /** Delete a path under `root`, refusing to escape it or remove a directory. */
@@ -110,8 +149,7 @@ export async function applyRestore(deps: RestoreDeps, input: RestoreInput): Prom
110
149
  const cur = await stagedTree(deps, input);
111
150
  const paths = await changedPaths(deps, input, cur);
112
151
  const { changed, removed } = summarize(paths);
113
- const rewritten = paths.filter((entry) => entry.change !== "added").map((entry) => entry.path);
114
- const created = paths.filter((entry) => entry.change === "added").map((entry) => entry.path);
152
+ const rewritten = rewrittenPaths(paths);
115
153
 
116
154
  if (input.paths) {
117
155
  await restorePaths(deps.runGit, input.root, input.target.commit, input.indexFile, rewritten);
@@ -120,7 +158,12 @@ export async function applyRestore(deps: RestoreDeps, input: RestoreInput): Prom
120
158
  // which a pathspec checkout of every changed path would not.
121
159
  await readTreeReset(deps.runGit, input.root, input.target.commit, input.indexFile);
122
160
  }
123
- for (const path of created) deleteFile(input.root, path);
161
+ for (const entry of paths) {
162
+ if (entry.change === "added") deleteFile(input.root, entry.path);
163
+ else if (entry.change === "renamed" && !sameFile(input.root, entry.from, entry.path)) {
164
+ deleteFile(input.root, entry.path);
165
+ }
166
+ }
124
167
 
125
168
  // A subset notice says "2 of 5 files", so it needs the whole diff's size; the
126
169
  // all-path query is already in hand when nothing was narrowed.