Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/core/src/commands/bottom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { list, status } from "../jj";
import { createError, err, type Result } from "../result";
import type { NavigationResult } from "../types";
import { navigateTo } from "./navigation";
import type { Command } from "./types";

/**
* Navigate to the bottom of the current stack.
*/
export async function bottom(): Promise<Result<NavigationResult>> {
const statusResult = await status();
if (!statusResult.ok) return statusResult;

const hasChanges = statusResult.value.modifiedFiles.length > 0;

if (hasChanges) {
return err(
createError(
"NAVIGATION_FAILED",
'You have unsaved changes. Run `arr create "message"` to save them.',
),
);
}

// Find roots of the current stack (changes between trunk and @-)
// Use @- since that's the current change (WC is on top)
const rootsResult = await list({ revset: "roots(trunk()..@-)" });
if (!rootsResult.ok) return rootsResult;

const roots = rootsResult.value.filter(
(c) => !c.changeId.startsWith("zzzzzzzz"),
);

if (roots.length === 0) {
return err(createError("NAVIGATION_FAILED", "Already at bottom of stack"));
}

if (roots.length > 1) {
return err(
createError(
"NAVIGATION_FAILED",
"Stack has multiple roots - cannot determine bottom",
),
);
}

return navigateTo(roots[0]);
}

export const bottomCommand: Command<NavigationResult> = {
meta: {
name: "bottom",
description: "Switch to the change closest to trunk in the current stack",
aliases: ["b"],
category: "navigation",
},
run: bottom,
};
38 changes: 38 additions & 0 deletions packages/core/src/commands/checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { resolveChange } from "../jj";
import type { Result } from "../result";
import type { NavigationResult } from "../types";
import { navigateTo, newOnTrunk } from "./navigation";
import type { Command } from "./types";

/**
* Checkout a change by its ID, bookmark, or search query.
* If checking out trunk/main/master, creates a new empty change on top.
*/
export async function checkout(
target: string,
): Promise<Result<NavigationResult>> {
// Handle trunk checkout - creates new empty change on main
if (target === "main" || target === "master" || target === "trunk") {
const trunkName = target === "trunk" ? "main" : target;
return newOnTrunk(trunkName);
}

// Resolve the change
const changeResult = await resolveChange(target, { includeBookmarks: true });
if (!changeResult.ok) return changeResult;

// Navigate to the change (handles immutability correctly)
return navigateTo(changeResult.value);
}

export const checkoutCommand: Command<NavigationResult, [string]> = {
meta: {
name: "checkout",
args: "[id]",
description: "Switch to a change by ID or description search",
aliases: ["co"],
category: "navigation",
core: true,
},
run: checkout,
};
92 changes: 92 additions & 0 deletions packages/core/src/commands/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { resolveBookmarkConflict } from "../bookmark-utils";
import type { Engine } from "../engine";
import { ensureBookmark, runJJ, status } from "../jj";
import { createError, err, ok, type Result } from "../result";
import { datePrefixedLabel } from "../slugify";
import type { Command } from "./types";

interface CreateResult {
changeId: string;
bookmarkName: string;
}

interface CreateOptions {
message: string;
engine: Engine;
}

/**
* Create a new change with the current file modifications.
* Sets up bookmark and prepares for PR submission.
* Tracks the new bookmark in the engine.
*/
export async function create(
options: CreateOptions,
): Promise<Result<CreateResult>> {
const { message, engine } = options;

const timestamp = new Date();
const initialBookmarkName = datePrefixedLabel(message, timestamp);

// Check GitHub for name conflicts
const conflictResult = await resolveBookmarkConflict(initialBookmarkName);
if (!conflictResult.ok) return conflictResult;

const bookmarkName = conflictResult.value.resolvedName;

// Get current working copy status
const statusResult = await status();
if (!statusResult.ok) return statusResult;

const wc = statusResult.value.workingCopy;
const hasChanges = statusResult.value.modifiedFiles.length > 0;

// Don't allow creating empty changes
if (!hasChanges) {
return err(
createError(
"EMPTY_CHANGE",
"No file changes to create. Make some changes first.",
),
);
}

// Describe the WC with the message (converts it from scratch to real change)
const describeResult = await runJJ(["describe", "-m", message]);
if (!describeResult.ok) return describeResult;

const createdChangeId = wc.changeId;

// Create new empty WC on top
const newResult = await runJJ(["new"]);
if (!newResult.ok) return newResult;

// Create bookmark pointing to the change
const bookmarkResult = await ensureBookmark(bookmarkName, createdChangeId);
if (!bookmarkResult.ok) return bookmarkResult;

// Export to git
const exportResult = await runJJ(["git", "export"]);
if (!exportResult.ok) return exportResult;

// Track the new bookmark in the engine by refreshing from jj
const refreshResult = await engine.refreshFromJJ(bookmarkName);
if (!refreshResult.ok) {
// This shouldn't happen since we just created the bookmark, but handle gracefully
return refreshResult;
}

return ok({ changeId: createdChangeId, bookmarkName });
}

export const createCommand: Command<CreateResult, [CreateOptions]> = {
meta: {
name: "create",
args: "[message]",
description: "Create a new change stacked on the current change",
aliases: ["c"],
category: "workflow",
core: true,
},
run: create,
};
110 changes: 110 additions & 0 deletions packages/core/src/commands/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { Engine } from "../engine";
import {
edit,
list,
resolveChange,
runJJWithMutableConfigVoid,
status,
} from "../jj";
import type { Changeset } from "../parser";
import { ok, type Result } from "../result";
import type { Command } from "./types";

interface DeleteResult {
movedTo: string | null;
untrackedBookmarks: string[];
/** The change that was deleted (for CLI display) */
change: Changeset;
}

interface DeleteOptions {
/** Change ID, bookmark name, or search query (required) */
id: string;
engine: Engine;
}

/**
* Delete a change, discarding its work.
* If the change has children, they are rebased onto the parent.
* If deleting the current change, moves to parent.
* Untracks any bookmarks on the deleted change from the engine.
*/
export async function deleteChange(
options: DeleteOptions,
): Promise<Result<DeleteResult>> {
const { id, engine } = options;

const statusBefore = await status();
if (!statusBefore.ok) return statusBefore;

// Resolve the change
const changeResult = await resolveChange(id, { includeBookmarks: true });
if (!changeResult.ok) return changeResult;
const change = changeResult.value;

const wasOnChange =
statusBefore.value.workingCopy.changeId === change.changeId;
const parentId = change.parents[0];

const childrenResult = await list({
revset: `children(${change.changeId})`,
});
const hasChildren = childrenResult.ok && childrenResult.value.length > 0;

// Use mutable config for operations on potentially pushed commits
if (hasChildren) {
const rebaseResult = await runJJWithMutableConfigVoid([
"rebase",
"-s",
`children(${change.changeId})`,
"-d",
parentId || "trunk()",
]);
if (!rebaseResult.ok) return rebaseResult;
}

// Discard work by restoring
const restoreResult = await runJJWithMutableConfigVoid([
"restore",
"--changes-in",
change.changeId,
]);
if (!restoreResult.ok) return restoreResult;

const abandonResult = await runJJWithMutableConfigVoid([
"abandon",
change.changeId,
]);
if (!abandonResult.ok) return abandonResult;

// Untrack any bookmarks on the deleted change
const untrackedBookmarks: string[] = [];
for (const bookmark of change.bookmarks) {
if (engine.isTracked(bookmark)) {
engine.untrack(bookmark);
untrackedBookmarks.push(bookmark);
}
}

let movedTo: string | null = null;
if (wasOnChange && parentId) {
const editResult = await edit(parentId);
if (editResult.ok) {
movedTo = parentId;
}
}

return ok({ movedTo, untrackedBookmarks, change });
}

export const deleteCommand: Command<DeleteResult, [DeleteOptions]> = {
meta: {
name: "delete",
args: "<id>",
description:
"Delete a change, discarding its work. Children restack onto parent.",
aliases: ["dl"],
category: "management",
},
run: deleteChange,
};
58 changes: 58 additions & 0 deletions packages/core/src/commands/down.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { getTrunk, list, status } from "../jj";
import { createError, err, type Result } from "../result";
import type { NavigationResult } from "../types";
import { navigateTo, newOnTrunk } from "./navigation";
import type { Command } from "./types";

/**
* Navigate down in the stack (to the parent of the current change).
* Current change is always @- (the parent of WC).
*/
export async function down(): Promise<Result<NavigationResult>> {
const statusResult = await status();
if (!statusResult.ok) return statusResult;

const trunk = await getTrunk();
const parents = statusResult.value.parents;
const hasChanges = statusResult.value.modifiedFiles.length > 0;

if (hasChanges) {
return err(
createError(
"NAVIGATION_FAILED",
'You have unsaved changes. Run `arr create "message"` to save them.',
),
);
}

if (parents.length === 0) {
return newOnTrunk(trunk);
}

const current = parents[0];

// Get current's parent
const parentsResult = await list({ revset: `${current.changeId}-` });
if (!parentsResult.ok) return parentsResult;

const grandparents = parentsResult.value.filter(
(c) => !c.changeId.startsWith("zzzzzzzz"),
);

if (grandparents.length === 0 || grandparents[0].bookmarks.includes(trunk)) {
return newOnTrunk(trunk);
}

return navigateTo(grandparents[0]);
}

export const downCommand: Command<NavigationResult> = {
meta: {
name: "down",
description: "Switch to the parent of the current change",
aliases: ["d"],
category: "navigation",
core: true,
},
run: down,
};
Loading
Loading