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
9 changes: 9 additions & 0 deletions packages/core/src/jj/abandon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Result } from "../result";
import { runJJVoid } from "./runner";

export async function abandon(
changeId: string,
cwd = process.cwd(),
): Promise<Result<void>> {
return runJJVoid(["abandon", changeId], cwd);
}
24 changes: 24 additions & 0 deletions packages/core/src/jj/bookmark-create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Result } from "../result";
import { runJJVoid } from "./runner";

async function createBookmark(
name: string,
revision?: string,
cwd = process.cwd(),
): Promise<Result<void>> {
const args = ["bookmark", "create", name];
if (revision) {
args.push("-r", revision);
}
return runJJVoid(args, cwd);
}

export async function ensureBookmark(
name: string,
changeId: string,
cwd = process.cwd(),
): Promise<Result<void>> {
const create = await createBookmark(name, changeId, cwd);
if (create.ok) return create;
return runJJVoid(["bookmark", "move", name, "-r", changeId], cwd);
}
9 changes: 9 additions & 0 deletions packages/core/src/jj/bookmark-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Result } from "../result";
import { runJJVoid } from "./runner";

export async function deleteBookmark(
name: string,
cwd = process.cwd(),
): Promise<Result<void>> {
return runJJVoid(["bookmark", "delete", name], cwd);
}
102 changes: 102 additions & 0 deletions packages/core/src/jj/bookmark-tracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { ok, type Result } from "../result";
import type { BookmarkTrackingStatus } from "../types";
import { runJJ } from "./runner";

export async function getBookmarkTracking(
cwd = process.cwd(),
): Promise<Result<BookmarkTrackingStatus[]>> {
// Template to get bookmark name + tracking status from origin
const template = `if(remote == "origin", name ++ "\\t" ++ tracking_ahead_count.exact() ++ "/" ++ tracking_behind_count.exact() ++ "\\n")`;
const result = await runJJ(["bookmark", "list", "-T", template], cwd);
if (!result.ok) return result;

const statuses: BookmarkTrackingStatus[] = [];
const lines = result.value.stdout.trim().split("\n").filter(Boolean);

for (const line of lines) {
const parts = line.split("\t");
if (parts.length !== 2) continue;
const [name, counts] = parts;
const [ahead, behind] = counts.split("/").map(Number);
if (!Number.isNaN(ahead) && !Number.isNaN(behind)) {
statuses.push({ name, aheadCount: ahead, behindCount: behind });
}
}

return ok(statuses);
}

/**
* Clean up orphaned bookmarks:
* 1. Local bookmarks marked as deleted (no target)
* 2. Local bookmarks without origin pointing to empty changes
*/
export async function cleanupOrphanedBookmarks(
cwd = process.cwd(),
): Promise<Result<string[]>> {
// Get all bookmarks with their remote status and target info
// Format: name\tremote_or_local\thas_target\tis_empty
const template =
'name ++ "\\t" ++ if(remote, remote, "local") ++ "\\t" ++ if(normal_target, "target", "no_target") ++ "\\t" ++ if(normal_target, normal_target.empty(), "") ++ "\\n"';
const result = await runJJ(
["bookmark", "list", "--all", "-T", template],
cwd,
);
if (!result.ok) return result;

// Parse bookmarks and group by name
const bookmarksByName = new Map<
string,
{ hasOrigin: boolean; hasLocalTarget: boolean; isEmpty: boolean }
>();

for (const line of result.value.stdout.trim().split("\n")) {
if (!line) continue;
const [name, remote, hasTarget, isEmpty] = line.split("\t");
if (!name) continue;

const existing = bookmarksByName.get(name);
if (remote === "origin") {
if (existing) {
existing.hasOrigin = true;
} else {
bookmarksByName.set(name, {
hasOrigin: true,
hasLocalTarget: false,
isEmpty: false,
});
}
} else if (remote === "local") {
const localHasTarget = hasTarget === "target";
const localIsEmpty = isEmpty === "true";
if (existing) {
existing.hasLocalTarget = localHasTarget;
existing.isEmpty = localIsEmpty;
} else {
bookmarksByName.set(name, {
hasOrigin: false,
hasLocalTarget: localHasTarget,
isEmpty: localIsEmpty,
});
}
}
}

// Find bookmarks to forget:
// 1. Deleted bookmarks (local has no target) - these show as "(deleted)"
// 2. Orphaned bookmarks (no origin AND empty change)
const forgotten: string[] = [];
for (const [name, info] of bookmarksByName) {
const isDeleted = !info.hasLocalTarget;
const isOrphaned = !info.hasOrigin && info.isEmpty;

if (isDeleted || isOrphaned) {
const forgetResult = await runJJ(["bookmark", "forget", name], cwd);
if (forgetResult.ok) {
forgotten.push(name);
}
}
}

return ok(forgotten);
}
15 changes: 15 additions & 0 deletions packages/core/src/jj/describe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Result } from "../result";
import { runJJVoid } from "./runner";

/**
* Set the description of a change.
* @param description The new description
* @param revision The revision to describe (default: @)
*/
export async function describe(
description: string,
revision = "@",
cwd = process.cwd(),
): Promise<Result<void>> {
return runJJVoid(["describe", "-m", description, revision], cwd);
}
54 changes: 54 additions & 0 deletions packages/core/src/jj/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ok, type Result } from "../result";
import type { DiffStats } from "../types";
import { runJJ } from "./runner";

function parseDiffStats(stdout: string): DiffStats {
// Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)"
// or just "X file changed, ..." for single file
const summaryMatch = stdout.match(
/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/,
);

if (summaryMatch) {
return {
filesChanged: parseInt(summaryMatch[1], 10),
insertions: summaryMatch[2] ? parseInt(summaryMatch[2], 10) : 0,
deletions: summaryMatch[3] ? parseInt(summaryMatch[3], 10) : 0,
};
}

// No changes
return { filesChanged: 0, insertions: 0, deletions: 0 };
}

/**
* Get diff stats for a revision.
* If fromBookmark is provided, compares against the remote version of that bookmark.
*/
export async function getDiffStats(
revision: string,
options?: { fromBookmark?: string },
cwd = process.cwd(),
): Promise<Result<DiffStats>> {
if (options?.fromBookmark) {
const result = await runJJ(
[
"diff",
"--from",
`${options.fromBookmark}@origin`,
"--to",
revision,
"--stat",
],
cwd,
);
if (!result.ok) {
// If remote doesn't exist, fall back to total diff
return getDiffStats(revision, undefined, cwd);
}
return ok(parseDiffStats(result.value.stdout));
}
const result = await runJJ(["diff", "-r", revision, "--stat"], cwd);
if (!result.ok) return result;
return ok(parseDiffStats(result.value.stdout));
}
9 changes: 9 additions & 0 deletions packages/core/src/jj/edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Result } from "../result";
import { runJJWithMutableConfigVoid } from "./runner";

export async function edit(
revision: string,
cwd = process.cwd(),
): Promise<Result<void>> {
return runJJWithMutableConfigVoid(["edit", revision], cwd);
}
86 changes: 86 additions & 0 deletions packages/core/src/jj/find.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Changeset } from "../parser";
import { createError, err, ok, type Result } from "../result";
import type { FindResult } from "../types";
import { list } from "./list";

export async function findChange(
query: string,
options: { includeBookmarks?: boolean } = {},
cwd = process.cwd(),
): Promise<Result<FindResult>> {
// First, try direct revset lookup (handles change IDs, commit IDs, shortest prefixes, etc.)
// Change IDs: lowercase letters + digits (e.g., xnkxvwyk)
// Commit IDs: hex digits (e.g., 1af471ab)
const isChangeId = /^[a-z][a-z0-9]*$/.test(query);
const isCommitId = /^[0-9a-f]+$/.test(query);

if (isChangeId || isCommitId) {
const idResult = await list({ revset: query, limit: 1 }, cwd);
if (idResult.ok && idResult.value.length === 1) {
return ok({ status: "found", change: idResult.value[0] });
}
}

// Search by description and bookmarks
// Escape backslashes first, then quotes
const escaped = query.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const revset = options.includeBookmarks
? `description(substring-i:"${escaped}") | bookmarks(substring-i:"${escaped}")`
: `description(substring-i:"${escaped}")`;

const listResult = await list({ revset }, cwd);
if (!listResult.ok) {
return ok({ status: "none" });
}

const matches = listResult.value.filter(
(cs) => !cs.changeId.startsWith("zzzzzzzz"),
);

if (matches.length === 0) {
return ok({ status: "none" });
}

// Check for exact bookmark match first
if (options.includeBookmarks) {
const exactBookmark = matches.find((cs) =>
cs.bookmarks.some((b) => b.toLowerCase() === query.toLowerCase()),
);
if (exactBookmark) {
return ok({ status: "found", change: exactBookmark });
}
}

if (matches.length === 1) {
return ok({ status: "found", change: matches[0] });
}

return ok({ status: "multiple", matches });
}

/**
* Resolve a target to a single Changeset, returning an error for not-found or ambiguous.
* This is a convenience wrapper around findChange that handles the common error patterns.
*/
export async function resolveChange(
target: string,
options: { includeBookmarks?: boolean } = {},
cwd = process.cwd(),
): Promise<Result<Changeset>> {
const findResult = await findChange(target, options, cwd);
if (!findResult.ok) return findResult;

if (findResult.value.status === "none") {
return err(createError("INVALID_REVISION", `Change not found: ${target}`));
}
if (findResult.value.status === "multiple") {
return err(
createError(
"AMBIGUOUS_REVISION",
`Multiple changes match "${target}". Use a more specific identifier.`,
),
);
}

return ok(findResult.value.change);
}
22 changes: 22 additions & 0 deletions packages/core/src/jj/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export { abandon } from "./abandon";
export { ensureBookmark } from "./bookmark-create";
export { deleteBookmark } from "./bookmark-delete";
export { getBookmarkTracking } from "./bookmark-tracking";
export { describe } from "./describe";
export { getDiffStats } from "./diff";
export { edit } from "./edit";
export { findChange, resolveChange } from "./find";
export { list } from "./list";
export { getLog } from "./log";
export { jjNew } from "./new";
export { push } from "./push";
export { rebase } from "./rebase";
export {
getTrunk,
runJJ,
runJJWithMutableConfig,
runJJWithMutableConfigVoid,
} from "./runner";
export { getStack } from "./stack";
export { status } from "./status";
export { sync } from "./sync";
24 changes: 24 additions & 0 deletions packages/core/src/jj/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { type Changeset, parseChangesets } from "../parser";
import type { Result } from "../result";
import { CHANGESET_JSON_TEMPLATE } from "../templates";
import type { ListOptions } from "../types";
import { runJJ } from "./runner";

export async function list(
options?: ListOptions,
cwd = process.cwd(),
): Promise<Result<Changeset[]>> {
const args = ["log", "--no-graph", "-T", CHANGESET_JSON_TEMPLATE];

if (options?.revset) {
args.push("-r", options.revset);
}
if (options?.limit) {
args.push("-n", String(options.limit));
}

const result = await runJJ(args, cwd);
if (!result.ok) return result;

return parseChangesets(result.value.stdout);
}
Loading
Loading