@signalridge/pi-worktree 1.2.0 → 1.3.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.
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/command.ts +50 -0
- package/src/git.ts +26 -0
- package/src/status.ts +237 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.1
|
|
4
|
+
### Patch Changes
|
|
5
|
+
|
|
6
|
+
- f714ea0: Publish the package versions already prepared by the previous release transition after its first publish attempt was blocked before npm publication.
|
|
7
|
+
|
|
8
|
+
## 1.3.0
|
|
9
|
+
### Minor Changes
|
|
10
|
+
|
|
11
|
+
- b6cf242: New `/worktree → Browse worktree status`: a readable view of what has actually changed in a worktree, grouped as conflicted / staged / unstaged / untracked.
|
|
12
|
+
|
|
13
|
+
It reads `git status --porcelain=v2`, not the v1 the existing safety check uses. That check is unchanged and stays on v1, correctly — it only needs to know whether a worktree is dirty, and v1's flat lines answer that in the fewest moving parts. This is the other job: a person reads the result before deciding whether to remove, switch away from, or commit in a worktree, and "3 changes" does not settle any of those. v2 is what makes the listing unambiguous — staged and unstaged as separate values rather than one overloaded pair of letters, a rename's similarity score *and* its original path, unmerged entries flagged as conflicts rather than rendered as a misleading staged/unstaged pair, and submodules distinguished from modified files.
|
|
14
|
+
|
|
15
|
+
Read with `-z`, so a path containing a newline or a quote is shown exactly as git wrote it. Parsing is total: an unrecognized record is skipped rather than throwing, so a future git version adding one costs that row and not the screen. `git.ts` keeps its raw-output boundary and stays free of intra-package imports — a test loads it standalone under Node's strip-only TypeScript, which cannot resolve a sibling `./x.js` to `x.ts` — so the parser lives beside it and is applied at the consumer.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- b6cf242: Peer dependency ranges now name the versions actually validated against, so an untested host combination fails at install time instead of silently at runtime: `@earendil-works/pi-coding-agent`, `pi-ai`, `pi-tui`, and `pi-agent-core` move from `"*"` to `^0.84.0`, and `typebox` from `"*"` to `^1.3.11`.
|
|
20
|
+
|
|
21
|
+
Shared dependencies now carry ONE declared range across every package that uses them, and `bun run check:shared-deps` keeps it that way.
|
|
22
|
+
|
|
23
|
+
`@narumitw/pi-tui-kit` was declared at three disjoint floors — `^0.54.0`, `^0.51.0`, and `^0.49.1` across nine packages — and the lockfile duly resolved three copies (0.54.0, 0.51.0, 0.49.3) installed side by side. For a shared rendering surface drawing into one terminal inside one host process, that means a theme rendering one way in one extension and another way in the next, with nothing failing at install to say so. All nine now declare `^0.54.0` and the install resolves a single copy. `@sinclair/typebox` likewise converges on `^0.34.50`.
|
|
24
|
+
|
|
25
|
+
The new check covers `dependencies` and `peerDependencies` and compares range strings rather than their semantics: two ranges that merely overlap are still a finding, because the goal is one intentional answer per dependency rather than an accidental intersection. `devDependencies` are deliberately out of scope — a build tool is not a shared surface, and `pi-subagents` intentionally carries its own toolchain. `docs/package-boundaries.md` documents the Kit as a shared surface for the first time.
|
|
26
|
+
|
|
27
|
+
## 1.2.1
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- 24a8af4: Remove references to the maintainer's personal dotfile setup from shipped docs
|
|
31
|
+
and source comments. Each constraint is restated as a property of the package
|
|
32
|
+
itself: which manager owns a settings file, whether a project permits git
|
|
33
|
+
worktrees, and installing a package from a single source. No runtime behavior
|
|
34
|
+
changes.
|
|
35
|
+
|
|
3
36
|
## 1.0.0
|
|
4
37
|
### Major Changes
|
|
5
38
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Pi cannot change its parent process working directory with `cd`. This extension performs the safe equivalent: it prepares a Pi session whose cwd is the selected worktree and switches to that session, preserving the current conversation when it has already been persisted.
|
|
8
8
|
|
|
9
|
-
This package is the full interactive worktree manager. It is publishable and stable,
|
|
9
|
+
This package is the full interactive worktree manager. It is publishable and stable, and opt-in: install it only if your project allows git worktrees. Some repositories forbid them outright — a worktree placed inside the source tree can be picked up by tooling that scans the tree, so check your repository's own guidance before enabling it.
|
|
10
10
|
|
|
11
11
|
## ✨ Features
|
|
12
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-worktree",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Pi extension for safe interactive Git worktree management and workspace switching.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"lint": "biome check ."
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@earendil-works/pi-coding-agent": "
|
|
38
|
+
"@earendil-works/pi-coding-agent": "^0.84.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@biomejs/biome": "2.5.7",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"access": "public"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
62
|
+
"@narumitw/pi-tui-kit": "^0.54.0",
|
|
63
63
|
"proper-lockfile": "^4.1.2"
|
|
64
64
|
}
|
|
65
65
|
}
|
package/src/command.ts
CHANGED
|
@@ -31,20 +31,24 @@ import {
|
|
|
31
31
|
worktreeAdministrativeDirectory,
|
|
32
32
|
worktreeForBranch,
|
|
33
33
|
worktreeInventory,
|
|
34
|
+
worktreeStatusOutput,
|
|
34
35
|
} from "./git.js";
|
|
35
36
|
import { removeWorktreeSafely } from "./safe-remove.js";
|
|
36
37
|
import { switchToWorktree } from "./session.js";
|
|
37
38
|
import type { WorktreeSettingsRuntime } from "./settings.js";
|
|
39
|
+
import { formatStatusEntry, groupStatusEntries, parsePorcelainV2, type StatusEntry } from "./status.js";
|
|
38
40
|
|
|
39
41
|
const ACTION_ADD = "Add worktree";
|
|
40
42
|
const ACTION_SWITCH = "Switch worktree";
|
|
41
43
|
const ACTION_REMOVE = "Remove worktree";
|
|
44
|
+
const ACTION_STATUS = "Browse worktree status";
|
|
42
45
|
const ACTION_PRUNE = "Prune stale metadata";
|
|
43
46
|
const ACTION_CONFIGURE_ROOT = "Configure worktree root";
|
|
44
47
|
const ACTIONS = {
|
|
45
48
|
add: ACTION_ADD,
|
|
46
49
|
switch: ACTION_SWITCH,
|
|
47
50
|
remove: ACTION_REMOVE,
|
|
51
|
+
status: ACTION_STATUS,
|
|
48
52
|
prune: ACTION_PRUNE,
|
|
49
53
|
configure: ACTION_CONFIGURE_ROOT,
|
|
50
54
|
} as const;
|
|
@@ -126,6 +130,7 @@ export function registerWorktreeCommand(
|
|
|
126
130
|
add: async () => runFlow(() => addFlow(pi, ctx, records, root.effectiveRoot)),
|
|
127
131
|
switch: async ({ signal }) => runFlow(() => switchFlow(pi, ctx, records, currentPath, signal)),
|
|
128
132
|
remove: async ({ signal }) => runFlow(() => removeFlow(pi, ctx, records, currentPath, signal)),
|
|
133
|
+
status: async ({ signal }) => runFlow(() => statusFlow(pi, ctx, records, currentPath, signal)),
|
|
129
134
|
prune: async () => runFlow(() => pruneFlow(pi, ctx, records)),
|
|
130
135
|
configure: async () => runFlow(() => configureRootFlow(ctx, settings)),
|
|
131
136
|
},
|
|
@@ -514,6 +519,49 @@ async function removeFlow(
|
|
|
514
519
|
safeNotify(ctx, `Removed worktree ${selected.path}. Its branch was preserved.`, "info");
|
|
515
520
|
}
|
|
516
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Show one worktree's status, grouped the way a commit is assembled.
|
|
524
|
+
*
|
|
525
|
+
* The point of a browser rather than a count is that "3 changes" is not enough
|
|
526
|
+
* to decide anything: removing a worktree, switching away from one, or
|
|
527
|
+
* committing in it are all decisions about WHICH changes exist. Conflicts lead
|
|
528
|
+
* because nothing else can proceed past them.
|
|
529
|
+
*/
|
|
530
|
+
async function statusFlow(
|
|
531
|
+
pi: ExtensionAPI,
|
|
532
|
+
ctx: ExtensionCommandContext,
|
|
533
|
+
records: readonly WorktreeRecord[],
|
|
534
|
+
currentPath: string,
|
|
535
|
+
signal?: AbortSignal,
|
|
536
|
+
): Promise<void> {
|
|
537
|
+
const target =
|
|
538
|
+
records.length === 1
|
|
539
|
+
? records[0]
|
|
540
|
+
: await selectWorktree(ctx, "Browse status of which worktree?", records, currentPath, signal);
|
|
541
|
+
if (!target) return;
|
|
542
|
+
|
|
543
|
+
const entries = parsePorcelainV2(await worktreeStatusOutput(pi, target.path, signal), "\0");
|
|
544
|
+
if (entries.length === 0) {
|
|
545
|
+
safeNotify(ctx, `${target.path} is clean.`, "info");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const groups = groupStatusEntries(entries);
|
|
550
|
+
const lines: string[] = [];
|
|
551
|
+
const section = (label: string, group: readonly StatusEntry[]) => {
|
|
552
|
+
if (group.length === 0) return;
|
|
553
|
+
lines.push(`${label} (${group.length}):`);
|
|
554
|
+
for (const entry of group) lines.push(` ${formatStatusEntry(entry)}`);
|
|
555
|
+
lines.push("");
|
|
556
|
+
};
|
|
557
|
+
section("Conflicted", groups.conflicted);
|
|
558
|
+
section("Staged", groups.staged);
|
|
559
|
+
section("Unstaged", groups.unstaged);
|
|
560
|
+
section("Untracked", groups.untracked);
|
|
561
|
+
|
|
562
|
+
safeNotify(ctx, [`${target.path}`, "", ...lines].join("\n").trimEnd(), "info");
|
|
563
|
+
}
|
|
564
|
+
|
|
517
565
|
async function pruneFlow(
|
|
518
566
|
pi: ExtensionAPI,
|
|
519
567
|
ctx: ExtensionCommandContext,
|
|
@@ -784,9 +832,11 @@ async function selectWorktree(
|
|
|
784
832
|
worktrees: () => ({
|
|
785
833
|
kind: "choice",
|
|
786
834
|
title,
|
|
835
|
+
enableSearch: true,
|
|
787
836
|
items: records.map((record, index) => ({
|
|
788
837
|
id: record.path,
|
|
789
838
|
label: `${index + 1}. ${formatWorktree(record, currentPath)}`,
|
|
839
|
+
searchText: `${record.branch ?? ""} ${record.path} ${formatWorktree(record, currentPath)}`,
|
|
790
840
|
})),
|
|
791
841
|
action: "choose",
|
|
792
842
|
hint: "close",
|
package/src/git.ts
CHANGED
|
@@ -572,6 +572,32 @@ export async function worktreeInventory(
|
|
|
572
572
|
];
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
+
/**
|
|
576
|
+
* A worktree's status as raw porcelain-v2 output, for the status browser.
|
|
577
|
+
*
|
|
578
|
+
* Separate from `worktreeInventory` on purpose: that reads porcelain v1 for the
|
|
579
|
+
* safety check, where a flat "is anything dirty" answer in the fewest moving
|
|
580
|
+
* parts is exactly right. This reads v2 because a person is going to READ the
|
|
581
|
+
* result, and v2 is the version that distinguishes staged from unstaged,
|
|
582
|
+
* carries a rename's original path and score, and names submodules. Ignored
|
|
583
|
+
* files are excluded: the safety check wants them, a reader does not.
|
|
584
|
+
*/
|
|
585
|
+
export async function worktreeStatusOutput(
|
|
586
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
587
|
+
path: string,
|
|
588
|
+
signal?: AbortSignal,
|
|
589
|
+
): Promise<string> {
|
|
590
|
+
// Raw output, parsed by the caller: this module deliberately has no
|
|
591
|
+
// intra-package imports (a test loads it standalone under Node's strip-only
|
|
592
|
+
// TypeScript, which cannot resolve a sibling `./x.js` to `x.ts`), so the
|
|
593
|
+
// parser lives in `status.ts` and is applied where the result is consumed.
|
|
594
|
+
//
|
|
595
|
+
// `-z` so a path containing a newline or a quote is read exactly as git wrote
|
|
596
|
+
// it, rather than through the quoting v1 output would need.
|
|
597
|
+
const result = await runGit(pi, ["status", "--porcelain=v2", "--untracked-files=all", "-z"], path, signal);
|
|
598
|
+
return result.stdout;
|
|
599
|
+
}
|
|
600
|
+
|
|
575
601
|
export async function worktreeAdministrativeDirectory(
|
|
576
602
|
pi: Pick<ExtensionAPI, "exec">,
|
|
577
603
|
cwd: string,
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status.ts — `git status --porcelain=v2`, parsed into something browsable.
|
|
3
|
+
*
|
|
4
|
+
* The safety check in `git.ts` already reads porcelain **v1**, and correctly:
|
|
5
|
+
* it only needs to know whether a worktree is dirty, and v1's flat lines answer
|
|
6
|
+
* that in the fewest moving parts. This is the other job — showing a person
|
|
7
|
+
* WHAT changed before they act on a worktree — and v1 is a poor source for it.
|
|
8
|
+
*
|
|
9
|
+
* v1 gives two status characters and a path. v2 gives the fields that make a
|
|
10
|
+
* listing readable and unambiguous:
|
|
11
|
+
*
|
|
12
|
+
* - staged and unstaged state as separate, explicit values, so "modified in
|
|
13
|
+
* both" is distinguishable from either alone;
|
|
14
|
+
* - renames and copies with their similarity score AND their original path,
|
|
15
|
+
* which v1 only hints at through an arrow inside the path field;
|
|
16
|
+
* - unmerged entries with their conflict stages, rather than one of v1's
|
|
17
|
+
* several overloaded two-letter codes;
|
|
18
|
+
* - a submodule field, so a dirty submodule is not silently a modified file.
|
|
19
|
+
*
|
|
20
|
+
* Parsing is deliberately total: an unrecognized line is skipped rather than
|
|
21
|
+
* throwing. This feeds a viewer, and a future git version adding a record type
|
|
22
|
+
* should cost that one row, not the whole screen.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** What happened to a path on one side (index or worktree). */
|
|
26
|
+
export type StatusChange = "unmodified" | "modified" | "added" | "deleted" | "renamed" | "copied" | "type-changed";
|
|
27
|
+
|
|
28
|
+
export interface StatusEntry {
|
|
29
|
+
path: string;
|
|
30
|
+
/** Staged state — what `git commit` would record. */
|
|
31
|
+
staged: StatusChange;
|
|
32
|
+
/** Unstaged state — what is different in the working tree. */
|
|
33
|
+
unstaged: StatusChange;
|
|
34
|
+
/** Present for renames and copies: where the content came from. */
|
|
35
|
+
originalPath?: string;
|
|
36
|
+
/** Rename/copy similarity, 0–100. */
|
|
37
|
+
similarity?: number;
|
|
38
|
+
/** True when the entry is a submodule rather than a file. */
|
|
39
|
+
submodule?: boolean;
|
|
40
|
+
/** Set on conflicts; the entry is neither staged nor unstaged but both. */
|
|
41
|
+
unmerged?: boolean;
|
|
42
|
+
untracked?: boolean;
|
|
43
|
+
ignored?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Single-letter XY codes shared by the ordinary and rename/copy records. */
|
|
47
|
+
const CHANGES: Readonly<Record<string, StatusChange>> = {
|
|
48
|
+
".": "unmodified",
|
|
49
|
+
M: "modified",
|
|
50
|
+
A: "added",
|
|
51
|
+
D: "deleted",
|
|
52
|
+
R: "renamed",
|
|
53
|
+
C: "copied",
|
|
54
|
+
T: "type-changed",
|
|
55
|
+
// Unmerged records reuse the same field for their conflict sides.
|
|
56
|
+
U: "modified",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function toChange(code: string | undefined): StatusChange {
|
|
60
|
+
return (code && CHANGES[code]) || "unmodified";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A submodule field is `N...` when the entry is not one, and `S` followed by
|
|
65
|
+
* three flags when it is. Only the distinction matters here.
|
|
66
|
+
*/
|
|
67
|
+
function isSubmodule(field: string | undefined): boolean {
|
|
68
|
+
return field?.startsWith("S") === true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse porcelain v2 output.
|
|
73
|
+
*
|
|
74
|
+
* `-z` output is NUL-separated and puts a rename's original path in its own
|
|
75
|
+
* record, so both separators are accepted: pass NUL-separated text and the
|
|
76
|
+
* paired paths are read correctly, or newline-separated text and the tab form
|
|
77
|
+
* is used instead. Callers that do not need exact paths under unusual filenames
|
|
78
|
+
* can use either; a viewer should prefer `-z`.
|
|
79
|
+
*/
|
|
80
|
+
export function parsePorcelainV2(output: string, separator: "\0" | "\n" = "\n"): StatusEntry[] {
|
|
81
|
+
const fields = output.split(separator);
|
|
82
|
+
const entries: StatusEntry[] = [];
|
|
83
|
+
|
|
84
|
+
for (let index = 0; index < fields.length; index++) {
|
|
85
|
+
const line = fields[index];
|
|
86
|
+
if (!line) continue;
|
|
87
|
+
|
|
88
|
+
// `? <path>` — untracked. Neither side has a change to describe.
|
|
89
|
+
if (line.startsWith("? ")) {
|
|
90
|
+
entries.push({ path: line.slice(2), staged: "unmodified", unstaged: "unmodified", untracked: true });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// `! <path>` — ignored. Only present when the caller asked for them.
|
|
94
|
+
if (line.startsWith("! ")) {
|
|
95
|
+
entries.push({ path: line.slice(2), staged: "unmodified", unstaged: "unmodified", ignored: true });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// `1 XY sub mH mI mW hH hI <path>` — an ordinary change.
|
|
100
|
+
if (line.startsWith("1 ")) {
|
|
101
|
+
const parts = line.split(" ");
|
|
102
|
+
const xy = parts[1] ?? "..";
|
|
103
|
+
// The path is the rest of the line: it may itself contain spaces.
|
|
104
|
+
const path = parts.slice(8).join(" ");
|
|
105
|
+
if (!path) continue;
|
|
106
|
+
entries.push({
|
|
107
|
+
path,
|
|
108
|
+
staged: toChange(xy[0]),
|
|
109
|
+
unstaged: toChange(xy[1]),
|
|
110
|
+
...(isSubmodule(parts[2]) ? { submodule: true } : {}),
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// `2 XY sub mH mI mW hH hI Xscore <path>` then the ORIGINAL path — a rename
|
|
116
|
+
// or copy. Under `-z` the original is the next NUL-separated field; under
|
|
117
|
+
// newlines it is tab-separated on the same line.
|
|
118
|
+
if (line.startsWith("2 ")) {
|
|
119
|
+
const parts = line.split(" ");
|
|
120
|
+
const xy = parts[1] ?? "..";
|
|
121
|
+
const score = parts[8] ?? "";
|
|
122
|
+
const rest = parts.slice(9).join(" ");
|
|
123
|
+
if (!rest) continue;
|
|
124
|
+
let path = rest;
|
|
125
|
+
let originalPath: string | undefined;
|
|
126
|
+
if (separator === "\0") {
|
|
127
|
+
originalPath = fields[index + 1] || undefined;
|
|
128
|
+
index += 1;
|
|
129
|
+
} else {
|
|
130
|
+
const tab = rest.indexOf("\t");
|
|
131
|
+
if (tab >= 0) {
|
|
132
|
+
path = rest.slice(0, tab);
|
|
133
|
+
originalPath = rest.slice(tab + 1) || undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const similarity = Number.parseInt(score.slice(1), 10);
|
|
137
|
+
entries.push({
|
|
138
|
+
path,
|
|
139
|
+
staged: toChange(xy[0]),
|
|
140
|
+
unstaged: toChange(xy[1]),
|
|
141
|
+
...(originalPath ? { originalPath } : {}),
|
|
142
|
+
...(Number.isFinite(similarity) ? { similarity } : {}),
|
|
143
|
+
...(isSubmodule(parts[2]) ? { submodule: true } : {}),
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// `u XY sub m1 m2 m3 mW h1 h2 h3 <path>` — unmerged. XY here is the pair of
|
|
149
|
+
// conflict sides rather than staged/unstaged, which is why the entry is
|
|
150
|
+
// flagged: rendering it as "staged X, unstaged Y" would be a lie.
|
|
151
|
+
if (line.startsWith("u ")) {
|
|
152
|
+
const parts = line.split(" ");
|
|
153
|
+
const xy = parts[1] ?? "..";
|
|
154
|
+
const path = parts.slice(10).join(" ");
|
|
155
|
+
if (!path) continue;
|
|
156
|
+
entries.push({
|
|
157
|
+
path,
|
|
158
|
+
staged: toChange(xy[0]),
|
|
159
|
+
unstaged: toChange(xy[1]),
|
|
160
|
+
unmerged: true,
|
|
161
|
+
...(isSubmodule(parts[2]) ? { submodule: true } : {}),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// `# ` header lines and anything unrecognized are skipped: this feeds a
|
|
165
|
+
// viewer, and a new record type should cost one row, not the screen.
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return entries;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Compact one-line label, e.g. `MM src/a.ts` or `R95 new.ts ← old.ts`. */
|
|
172
|
+
export function formatStatusEntry(entry: StatusEntry): string {
|
|
173
|
+
if (entry.untracked) return `?? ${entry.path}`;
|
|
174
|
+
if (entry.ignored) return `!! ${entry.path}`;
|
|
175
|
+
const code = `${letterFor(entry.staged)}${letterFor(entry.unstaged)}`;
|
|
176
|
+
const marks = [
|
|
177
|
+
entry.unmerged ? "conflict" : undefined,
|
|
178
|
+
entry.submodule ? "submodule" : undefined,
|
|
179
|
+
entry.similarity !== undefined ? `${entry.similarity}%` : undefined,
|
|
180
|
+
].filter(Boolean);
|
|
181
|
+
const origin = entry.originalPath ? ` ← ${entry.originalPath}` : "";
|
|
182
|
+
const suffix = marks.length > 0 ? ` (${marks.join(", ")})` : "";
|
|
183
|
+
return `${code} ${entry.path}${origin}${suffix}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function letterFor(change: StatusChange): string {
|
|
187
|
+
switch (change) {
|
|
188
|
+
case "modified":
|
|
189
|
+
return "M";
|
|
190
|
+
case "added":
|
|
191
|
+
return "A";
|
|
192
|
+
case "deleted":
|
|
193
|
+
return "D";
|
|
194
|
+
case "renamed":
|
|
195
|
+
return "R";
|
|
196
|
+
case "copied":
|
|
197
|
+
return "C";
|
|
198
|
+
case "type-changed":
|
|
199
|
+
return "T";
|
|
200
|
+
default:
|
|
201
|
+
return ".";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Group entries the way a reader thinks about them, in the order a commit is
|
|
207
|
+
* assembled: conflicts first because nothing else can proceed past them, then
|
|
208
|
+
* what is staged, then what is not, then what is not tracked at all.
|
|
209
|
+
*/
|
|
210
|
+
export function groupStatusEntries(entries: readonly StatusEntry[]): {
|
|
211
|
+
conflicted: StatusEntry[];
|
|
212
|
+
staged: StatusEntry[];
|
|
213
|
+
unstaged: StatusEntry[];
|
|
214
|
+
untracked: StatusEntry[];
|
|
215
|
+
ignored: StatusEntry[];
|
|
216
|
+
} {
|
|
217
|
+
const groups = {
|
|
218
|
+
conflicted: [] as StatusEntry[],
|
|
219
|
+
staged: [] as StatusEntry[],
|
|
220
|
+
unstaged: [] as StatusEntry[],
|
|
221
|
+
untracked: [] as StatusEntry[],
|
|
222
|
+
ignored: [] as StatusEntry[],
|
|
223
|
+
};
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (entry.unmerged) groups.conflicted.push(entry);
|
|
226
|
+
else if (entry.untracked) groups.untracked.push(entry);
|
|
227
|
+
else if (entry.ignored) groups.ignored.push(entry);
|
|
228
|
+
else {
|
|
229
|
+
// A path can be in both: staged edits plus further unstaged ones. It is
|
|
230
|
+
// listed under each, because "have I staged everything?" and "what is
|
|
231
|
+
// still uncommitted?" are different questions with different answers.
|
|
232
|
+
if (entry.staged !== "unmodified") groups.staged.push(entry);
|
|
233
|
+
if (entry.unstaged !== "unmodified") groups.unstaged.push(entry);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return groups;
|
|
237
|
+
}
|