Skip to content

Commit 3579e2d

Browse files
committed
feat: add @array/core jj wrappers
1 parent d3ece55 commit 3579e2d

18 files changed

Lines changed: 891 additions & 0 deletions

packages/core/src/jj/abandon.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Result } from "../result";
2+
import { runJJVoid } from "./runner";
3+
4+
export async function abandon(
5+
changeId: string,
6+
cwd = process.cwd(),
7+
): Promise<Result<void>> {
8+
return runJJVoid(["abandon", changeId], cwd);
9+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Result } from "../result";
2+
import { runJJVoid } from "./runner";
3+
4+
async function createBookmark(
5+
name: string,
6+
revision?: string,
7+
cwd = process.cwd(),
8+
): Promise<Result<void>> {
9+
const args = ["bookmark", "create", name];
10+
if (revision) {
11+
args.push("-r", revision);
12+
}
13+
return runJJVoid(args, cwd);
14+
}
15+
16+
export async function ensureBookmark(
17+
name: string,
18+
changeId: string,
19+
cwd = process.cwd(),
20+
): Promise<Result<void>> {
21+
const create = await createBookmark(name, changeId, cwd);
22+
if (create.ok) return create;
23+
return runJJVoid(["bookmark", "move", name, "-r", changeId], cwd);
24+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Result } from "../result";
2+
import { runJJVoid } from "./runner";
3+
4+
export async function deleteBookmark(
5+
name: string,
6+
cwd = process.cwd(),
7+
): Promise<Result<void>> {
8+
return runJJVoid(["bookmark", "delete", name], cwd);
9+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { ok, type Result } from "../result";
2+
import type { BookmarkTrackingStatus } from "../types";
3+
import { runJJ } from "./runner";
4+
5+
export async function getBookmarkTracking(
6+
cwd = process.cwd(),
7+
): Promise<Result<BookmarkTrackingStatus[]>> {
8+
// Template to get bookmark name + tracking status from origin
9+
const template = `if(remote == "origin", name ++ "\\t" ++ tracking_ahead_count.exact() ++ "/" ++ tracking_behind_count.exact() ++ "\\n")`;
10+
const result = await runJJ(["bookmark", "list", "-T", template], cwd);
11+
if (!result.ok) return result;
12+
13+
const statuses: BookmarkTrackingStatus[] = [];
14+
const lines = result.value.stdout.trim().split("\n").filter(Boolean);
15+
16+
for (const line of lines) {
17+
const parts = line.split("\t");
18+
if (parts.length !== 2) continue;
19+
const [name, counts] = parts;
20+
const [ahead, behind] = counts.split("/").map(Number);
21+
if (!Number.isNaN(ahead) && !Number.isNaN(behind)) {
22+
statuses.push({ name, aheadCount: ahead, behindCount: behind });
23+
}
24+
}
25+
26+
return ok(statuses);
27+
}
28+
29+
/**
30+
* Clean up orphaned bookmarks:
31+
* 1. Local bookmarks marked as deleted (no target)
32+
* 2. Local bookmarks without origin pointing to empty changes
33+
*/
34+
export async function cleanupOrphanedBookmarks(
35+
cwd = process.cwd(),
36+
): Promise<Result<string[]>> {
37+
// Get all bookmarks with their remote status and target info
38+
// Format: name\tremote_or_local\thas_target\tis_empty
39+
const template =
40+
'name ++ "\\t" ++ if(remote, remote, "local") ++ "\\t" ++ if(normal_target, "target", "no_target") ++ "\\t" ++ if(normal_target, normal_target.empty(), "") ++ "\\n"';
41+
const result = await runJJ(
42+
["bookmark", "list", "--all", "-T", template],
43+
cwd,
44+
);
45+
if (!result.ok) return result;
46+
47+
// Parse bookmarks and group by name
48+
const bookmarksByName = new Map<
49+
string,
50+
{ hasOrigin: boolean; hasLocalTarget: boolean; isEmpty: boolean }
51+
>();
52+
53+
for (const line of result.value.stdout.trim().split("\n")) {
54+
if (!line) continue;
55+
const [name, remote, hasTarget, isEmpty] = line.split("\t");
56+
if (!name) continue;
57+
58+
const existing = bookmarksByName.get(name);
59+
if (remote === "origin") {
60+
if (existing) {
61+
existing.hasOrigin = true;
62+
} else {
63+
bookmarksByName.set(name, {
64+
hasOrigin: true,
65+
hasLocalTarget: false,
66+
isEmpty: false,
67+
});
68+
}
69+
} else if (remote === "local") {
70+
const localHasTarget = hasTarget === "target";
71+
const localIsEmpty = isEmpty === "true";
72+
if (existing) {
73+
existing.hasLocalTarget = localHasTarget;
74+
existing.isEmpty = localIsEmpty;
75+
} else {
76+
bookmarksByName.set(name, {
77+
hasOrigin: false,
78+
hasLocalTarget: localHasTarget,
79+
isEmpty: localIsEmpty,
80+
});
81+
}
82+
}
83+
}
84+
85+
// Find bookmarks to forget:
86+
// 1. Deleted bookmarks (local has no target) - these show as "(deleted)"
87+
// 2. Orphaned bookmarks (no origin AND empty change)
88+
const forgotten: string[] = [];
89+
for (const [name, info] of bookmarksByName) {
90+
const isDeleted = !info.hasLocalTarget;
91+
const isOrphaned = !info.hasOrigin && info.isEmpty;
92+
93+
if (isDeleted || isOrphaned) {
94+
const forgetResult = await runJJ(["bookmark", "forget", name], cwd);
95+
if (forgetResult.ok) {
96+
forgotten.push(name);
97+
}
98+
}
99+
}
100+
101+
return ok(forgotten);
102+
}

packages/core/src/jj/describe.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Result } from "../result";
2+
import { runJJVoid } from "./runner";
3+
4+
/**
5+
* Set the description of a change.
6+
* @param description The new description
7+
* @param revision The revision to describe (default: @)
8+
*/
9+
export async function describe(
10+
description: string,
11+
revision = "@",
12+
cwd = process.cwd(),
13+
): Promise<Result<void>> {
14+
return runJJVoid(["describe", "-m", description, revision], cwd);
15+
}

packages/core/src/jj/diff.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { ok, type Result } from "../result";
2+
import type { DiffStats } from "../types";
3+
import { runJJ } from "./runner";
4+
5+
function parseDiffStats(stdout: string): DiffStats {
6+
// Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)"
7+
// or just "X file changed, ..." for single file
8+
const summaryMatch = stdout.match(
9+
/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/,
10+
);
11+
12+
if (summaryMatch) {
13+
return {
14+
filesChanged: parseInt(summaryMatch[1], 10),
15+
insertions: summaryMatch[2] ? parseInt(summaryMatch[2], 10) : 0,
16+
deletions: summaryMatch[3] ? parseInt(summaryMatch[3], 10) : 0,
17+
};
18+
}
19+
20+
// No changes
21+
return { filesChanged: 0, insertions: 0, deletions: 0 };
22+
}
23+
24+
/**
25+
* Get diff stats for a revision.
26+
* If fromBookmark is provided, compares against the remote version of that bookmark.
27+
*/
28+
export async function getDiffStats(
29+
revision: string,
30+
options?: { fromBookmark?: string },
31+
cwd = process.cwd(),
32+
): Promise<Result<DiffStats>> {
33+
if (options?.fromBookmark) {
34+
const result = await runJJ(
35+
[
36+
"diff",
37+
"--from",
38+
`${options.fromBookmark}@origin`,
39+
"--to",
40+
revision,
41+
"--stat",
42+
],
43+
cwd,
44+
);
45+
if (!result.ok) {
46+
// If remote doesn't exist, fall back to total diff
47+
return getDiffStats(revision, undefined, cwd);
48+
}
49+
return ok(parseDiffStats(result.value.stdout));
50+
}
51+
const result = await runJJ(["diff", "-r", revision, "--stat"], cwd);
52+
if (!result.ok) return result;
53+
return ok(parseDiffStats(result.value.stdout));
54+
}

packages/core/src/jj/edit.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Result } from "../result";
2+
import { runJJWithMutableConfigVoid } from "./runner";
3+
4+
export async function edit(
5+
revision: string,
6+
cwd = process.cwd(),
7+
): Promise<Result<void>> {
8+
return runJJWithMutableConfigVoid(["edit", revision], cwd);
9+
}

packages/core/src/jj/find.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { Changeset } from "../parser";
2+
import { createError, err, ok, type Result } from "../result";
3+
import type { FindResult } from "../types";
4+
import { list } from "./list";
5+
6+
export async function findChange(
7+
query: string,
8+
options: { includeBookmarks?: boolean } = {},
9+
cwd = process.cwd(),
10+
): Promise<Result<FindResult>> {
11+
// First, try direct revset lookup (handles change IDs, commit IDs, shortest prefixes, etc.)
12+
// Change IDs: lowercase letters + digits (e.g., xnkxvwyk)
13+
// Commit IDs: hex digits (e.g., 1af471ab)
14+
const isChangeId = /^[a-z][a-z0-9]*$/.test(query);
15+
const isCommitId = /^[0-9a-f]+$/.test(query);
16+
17+
if (isChangeId || isCommitId) {
18+
const idResult = await list({ revset: query, limit: 1 }, cwd);
19+
if (idResult.ok && idResult.value.length === 1) {
20+
return ok({ status: "found", change: idResult.value[0] });
21+
}
22+
}
23+
24+
// Search by description and bookmarks
25+
// Escape backslashes first, then quotes
26+
const escaped = query.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
27+
const revset = options.includeBookmarks
28+
? `description(substring-i:"${escaped}") | bookmarks(substring-i:"${escaped}")`
29+
: `description(substring-i:"${escaped}")`;
30+
31+
const listResult = await list({ revset }, cwd);
32+
if (!listResult.ok) {
33+
return ok({ status: "none" });
34+
}
35+
36+
const matches = listResult.value.filter(
37+
(cs) => !cs.changeId.startsWith("zzzzzzzz"),
38+
);
39+
40+
if (matches.length === 0) {
41+
return ok({ status: "none" });
42+
}
43+
44+
// Check for exact bookmark match first
45+
if (options.includeBookmarks) {
46+
const exactBookmark = matches.find((cs) =>
47+
cs.bookmarks.some((b) => b.toLowerCase() === query.toLowerCase()),
48+
);
49+
if (exactBookmark) {
50+
return ok({ status: "found", change: exactBookmark });
51+
}
52+
}
53+
54+
if (matches.length === 1) {
55+
return ok({ status: "found", change: matches[0] });
56+
}
57+
58+
return ok({ status: "multiple", matches });
59+
}
60+
61+
/**
62+
* Resolve a target to a single Changeset, returning an error for not-found or ambiguous.
63+
* This is a convenience wrapper around findChange that handles the common error patterns.
64+
*/
65+
export async function resolveChange(
66+
target: string,
67+
options: { includeBookmarks?: boolean } = {},
68+
cwd = process.cwd(),
69+
): Promise<Result<Changeset>> {
70+
const findResult = await findChange(target, options, cwd);
71+
if (!findResult.ok) return findResult;
72+
73+
if (findResult.value.status === "none") {
74+
return err(createError("INVALID_REVISION", `Change not found: ${target}`));
75+
}
76+
if (findResult.value.status === "multiple") {
77+
return err(
78+
createError(
79+
"AMBIGUOUS_REVISION",
80+
`Multiple changes match "${target}". Use a more specific identifier.`,
81+
),
82+
);
83+
}
84+
85+
return ok(findResult.value.change);
86+
}

packages/core/src/jj/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export { abandon } from "./abandon";
2+
export { ensureBookmark } from "./bookmark-create";
3+
export { deleteBookmark } from "./bookmark-delete";
4+
export { getBookmarkTracking } from "./bookmark-tracking";
5+
export { describe } from "./describe";
6+
export { getDiffStats } from "./diff";
7+
export { edit } from "./edit";
8+
export { findChange, resolveChange } from "./find";
9+
export { list } from "./list";
10+
export { getLog } from "./log";
11+
export { jjNew } from "./new";
12+
export { push } from "./push";
13+
export { rebase } from "./rebase";
14+
export {
15+
getTrunk,
16+
runJJ,
17+
runJJWithMutableConfig,
18+
runJJWithMutableConfigVoid,
19+
} from "./runner";
20+
export { getStack } from "./stack";
21+
export { status } from "./status";
22+
export { sync } from "./sync";

packages/core/src/jj/list.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { type Changeset, parseChangesets } from "../parser";
2+
import type { Result } from "../result";
3+
import { CHANGESET_JSON_TEMPLATE } from "../templates";
4+
import type { ListOptions } from "../types";
5+
import { runJJ } from "./runner";
6+
7+
export async function list(
8+
options?: ListOptions,
9+
cwd = process.cwd(),
10+
): Promise<Result<Changeset[]>> {
11+
const args = ["log", "--no-graph", "-T", CHANGESET_JSON_TEMPLATE];
12+
13+
if (options?.revset) {
14+
args.push("-r", options.revset);
15+
}
16+
if (options?.limit) {
17+
args.push("-n", String(options.limit));
18+
}
19+
20+
const result = await runJJ(args, cwd);
21+
if (!result.ok) return result;
22+
23+
return parseChangesets(result.value.stdout);
24+
}

0 commit comments

Comments
 (0)