Skip to content

Commit 9bde0dd

Browse files
authored
feat: add @array/core commands (#432)
1 parent 8067615 commit 9bde0dd

25 files changed

+3348
-0
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { list, status } from "../jj";
2+
import { createError, err, type Result } from "../result";
3+
import type { NavigationResult } from "../types";
4+
import { navigateTo } from "./navigation";
5+
import type { Command } from "./types";
6+
7+
/**
8+
* Navigate to the bottom of the current stack.
9+
*/
10+
export async function bottom(): Promise<Result<NavigationResult>> {
11+
const statusResult = await status();
12+
if (!statusResult.ok) return statusResult;
13+
14+
const hasChanges = statusResult.value.modifiedFiles.length > 0;
15+
16+
if (hasChanges) {
17+
return err(
18+
createError(
19+
"NAVIGATION_FAILED",
20+
'You have unsaved changes. Run `arr create "message"` to save them.',
21+
),
22+
);
23+
}
24+
25+
// Find roots of the current stack (changes between trunk and @-)
26+
// Use @- since that's the current change (WC is on top)
27+
const rootsResult = await list({ revset: "roots(trunk()..@-)" });
28+
if (!rootsResult.ok) return rootsResult;
29+
30+
const roots = rootsResult.value.filter(
31+
(c) => !c.changeId.startsWith("zzzzzzzz"),
32+
);
33+
34+
if (roots.length === 0) {
35+
return err(createError("NAVIGATION_FAILED", "Already at bottom of stack"));
36+
}
37+
38+
if (roots.length > 1) {
39+
return err(
40+
createError(
41+
"NAVIGATION_FAILED",
42+
"Stack has multiple roots - cannot determine bottom",
43+
),
44+
);
45+
}
46+
47+
return navigateTo(roots[0]);
48+
}
49+
50+
export const bottomCommand: Command<NavigationResult> = {
51+
meta: {
52+
name: "bottom",
53+
description: "Switch to the change closest to trunk in the current stack",
54+
aliases: ["b"],
55+
category: "navigation",
56+
},
57+
run: bottom,
58+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { resolveChange } from "../jj";
2+
import type { Result } from "../result";
3+
import type { NavigationResult } from "../types";
4+
import { navigateTo, newOnTrunk } from "./navigation";
5+
import type { Command } from "./types";
6+
7+
/**
8+
* Checkout a change by its ID, bookmark, or search query.
9+
* If checking out trunk/main/master, creates a new empty change on top.
10+
*/
11+
export async function checkout(
12+
target: string,
13+
): Promise<Result<NavigationResult>> {
14+
// Handle trunk checkout - creates new empty change on main
15+
if (target === "main" || target === "master" || target === "trunk") {
16+
const trunkName = target === "trunk" ? "main" : target;
17+
return newOnTrunk(trunkName);
18+
}
19+
20+
// Resolve the change
21+
const changeResult = await resolveChange(target, { includeBookmarks: true });
22+
if (!changeResult.ok) return changeResult;
23+
24+
// Navigate to the change (handles immutability correctly)
25+
return navigateTo(changeResult.value);
26+
}
27+
28+
export const checkoutCommand: Command<NavigationResult, [string]> = {
29+
meta: {
30+
name: "checkout",
31+
args: "[id]",
32+
description: "Switch to a change by ID or description search",
33+
aliases: ["co"],
34+
category: "navigation",
35+
core: true,
36+
},
37+
run: checkout,
38+
};
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { resolveBookmarkConflict } from "../bookmark-utils";
2+
import type { Engine } from "../engine";
3+
import { ensureBookmark, runJJ, status } from "../jj";
4+
import { createError, err, ok, type Result } from "../result";
5+
import { datePrefixedLabel } from "../slugify";
6+
import type { Command } from "./types";
7+
8+
interface CreateResult {
9+
changeId: string;
10+
bookmarkName: string;
11+
}
12+
13+
interface CreateOptions {
14+
message: string;
15+
engine: Engine;
16+
}
17+
18+
/**
19+
* Create a new change with the current file modifications.
20+
* Sets up bookmark and prepares for PR submission.
21+
* Tracks the new bookmark in the engine.
22+
*/
23+
export async function create(
24+
options: CreateOptions,
25+
): Promise<Result<CreateResult>> {
26+
const { message, engine } = options;
27+
28+
const timestamp = new Date();
29+
const initialBookmarkName = datePrefixedLabel(message, timestamp);
30+
31+
// Check GitHub for name conflicts
32+
const conflictResult = await resolveBookmarkConflict(initialBookmarkName);
33+
if (!conflictResult.ok) return conflictResult;
34+
35+
const bookmarkName = conflictResult.value.resolvedName;
36+
37+
// Get current working copy status
38+
const statusResult = await status();
39+
if (!statusResult.ok) return statusResult;
40+
41+
const wc = statusResult.value.workingCopy;
42+
const hasChanges = statusResult.value.modifiedFiles.length > 0;
43+
44+
// Don't allow creating empty changes
45+
if (!hasChanges) {
46+
return err(
47+
createError(
48+
"EMPTY_CHANGE",
49+
"No file changes to create. Make some changes first.",
50+
),
51+
);
52+
}
53+
54+
// Describe the WC with the message (converts it from scratch to real change)
55+
const describeResult = await runJJ(["describe", "-m", message]);
56+
if (!describeResult.ok) return describeResult;
57+
58+
const createdChangeId = wc.changeId;
59+
60+
// Create new empty WC on top
61+
const newResult = await runJJ(["new"]);
62+
if (!newResult.ok) return newResult;
63+
64+
// Create bookmark pointing to the change
65+
const bookmarkResult = await ensureBookmark(bookmarkName, createdChangeId);
66+
if (!bookmarkResult.ok) return bookmarkResult;
67+
68+
// Export to git
69+
const exportResult = await runJJ(["git", "export"]);
70+
if (!exportResult.ok) return exportResult;
71+
72+
// Track the new bookmark in the engine by refreshing from jj
73+
const refreshResult = await engine.refreshFromJJ(bookmarkName);
74+
if (!refreshResult.ok) {
75+
// This shouldn't happen since we just created the bookmark, but handle gracefully
76+
return refreshResult;
77+
}
78+
79+
return ok({ changeId: createdChangeId, bookmarkName });
80+
}
81+
82+
export const createCommand: Command<CreateResult, [CreateOptions]> = {
83+
meta: {
84+
name: "create",
85+
args: "[message]",
86+
description: "Create a new change stacked on the current change",
87+
aliases: ["c"],
88+
category: "workflow",
89+
core: true,
90+
},
91+
run: create,
92+
};
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { Engine } from "../engine";
2+
import {
3+
edit,
4+
list,
5+
resolveChange,
6+
runJJWithMutableConfigVoid,
7+
status,
8+
} from "../jj";
9+
import type { Changeset } from "../parser";
10+
import { ok, type Result } from "../result";
11+
import type { Command } from "./types";
12+
13+
interface DeleteResult {
14+
movedTo: string | null;
15+
untrackedBookmarks: string[];
16+
/** The change that was deleted (for CLI display) */
17+
change: Changeset;
18+
}
19+
20+
interface DeleteOptions {
21+
/** Change ID, bookmark name, or search query (required) */
22+
id: string;
23+
engine: Engine;
24+
}
25+
26+
/**
27+
* Delete a change, discarding its work.
28+
* If the change has children, they are rebased onto the parent.
29+
* If deleting the current change, moves to parent.
30+
* Untracks any bookmarks on the deleted change from the engine.
31+
*/
32+
export async function deleteChange(
33+
options: DeleteOptions,
34+
): Promise<Result<DeleteResult>> {
35+
const { id, engine } = options;
36+
37+
const statusBefore = await status();
38+
if (!statusBefore.ok) return statusBefore;
39+
40+
// Resolve the change
41+
const changeResult = await resolveChange(id, { includeBookmarks: true });
42+
if (!changeResult.ok) return changeResult;
43+
const change = changeResult.value;
44+
45+
const wasOnChange =
46+
statusBefore.value.workingCopy.changeId === change.changeId;
47+
const parentId = change.parents[0];
48+
49+
const childrenResult = await list({
50+
revset: `children(${change.changeId})`,
51+
});
52+
const hasChildren = childrenResult.ok && childrenResult.value.length > 0;
53+
54+
// Use mutable config for operations on potentially pushed commits
55+
if (hasChildren) {
56+
const rebaseResult = await runJJWithMutableConfigVoid([
57+
"rebase",
58+
"-s",
59+
`children(${change.changeId})`,
60+
"-d",
61+
parentId || "trunk()",
62+
]);
63+
if (!rebaseResult.ok) return rebaseResult;
64+
}
65+
66+
// Discard work by restoring
67+
const restoreResult = await runJJWithMutableConfigVoid([
68+
"restore",
69+
"--changes-in",
70+
change.changeId,
71+
]);
72+
if (!restoreResult.ok) return restoreResult;
73+
74+
const abandonResult = await runJJWithMutableConfigVoid([
75+
"abandon",
76+
change.changeId,
77+
]);
78+
if (!abandonResult.ok) return abandonResult;
79+
80+
// Untrack any bookmarks on the deleted change
81+
const untrackedBookmarks: string[] = [];
82+
for (const bookmark of change.bookmarks) {
83+
if (engine.isTracked(bookmark)) {
84+
engine.untrack(bookmark);
85+
untrackedBookmarks.push(bookmark);
86+
}
87+
}
88+
89+
let movedTo: string | null = null;
90+
if (wasOnChange && parentId) {
91+
const editResult = await edit(parentId);
92+
if (editResult.ok) {
93+
movedTo = parentId;
94+
}
95+
}
96+
97+
return ok({ movedTo, untrackedBookmarks, change });
98+
}
99+
100+
export const deleteCommand: Command<DeleteResult, [DeleteOptions]> = {
101+
meta: {
102+
name: "delete",
103+
args: "<id>",
104+
description:
105+
"Delete a change, discarding its work. Children restack onto parent.",
106+
aliases: ["dl"],
107+
category: "management",
108+
},
109+
run: deleteChange,
110+
};

packages/core/src/commands/down.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { getTrunk, list, status } from "../jj";
2+
import { createError, err, type Result } from "../result";
3+
import type { NavigationResult } from "../types";
4+
import { navigateTo, newOnTrunk } from "./navigation";
5+
import type { Command } from "./types";
6+
7+
/**
8+
* Navigate down in the stack (to the parent of the current change).
9+
* Current change is always @- (the parent of WC).
10+
*/
11+
export async function down(): Promise<Result<NavigationResult>> {
12+
const statusResult = await status();
13+
if (!statusResult.ok) return statusResult;
14+
15+
const trunk = await getTrunk();
16+
const parents = statusResult.value.parents;
17+
const hasChanges = statusResult.value.modifiedFiles.length > 0;
18+
19+
if (hasChanges) {
20+
return err(
21+
createError(
22+
"NAVIGATION_FAILED",
23+
'You have unsaved changes. Run `arr create "message"` to save them.',
24+
),
25+
);
26+
}
27+
28+
if (parents.length === 0) {
29+
return newOnTrunk(trunk);
30+
}
31+
32+
const current = parents[0];
33+
34+
// Get current's parent
35+
const parentsResult = await list({ revset: `${current.changeId}-` });
36+
if (!parentsResult.ok) return parentsResult;
37+
38+
const grandparents = parentsResult.value.filter(
39+
(c) => !c.changeId.startsWith("zzzzzzzz"),
40+
);
41+
42+
if (grandparents.length === 0 || grandparents[0].bookmarks.includes(trunk)) {
43+
return newOnTrunk(trunk);
44+
}
45+
46+
return navigateTo(grandparents[0]);
47+
}
48+
49+
export const downCommand: Command<NavigationResult> = {
50+
meta: {
51+
name: "down",
52+
description: "Switch to the parent of the current change",
53+
aliases: ["d"],
54+
category: "navigation",
55+
core: true,
56+
},
57+
run: down,
58+
};

0 commit comments

Comments
 (0)