Skip to content
Closed
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
68 changes: 17 additions & 51 deletions apps/cli/src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { restack as coreRestack } from "@array/core/commands/restack";
import { sync as coreSync } from "@array/core/commands/sync";
import type { ArrContext, Engine } from "@array/core/engine";
import { deleteBookmark } from "@array/core/jj";
import { type MergedChange, reparentAndCleanup } from "@array/core/stacks";
import { COMMANDS } from "../registry";
import {
arr,
Expand All @@ -16,67 +16,34 @@ import {
import { confirm } from "../utils/prompt";
import { unwrap } from "../utils/run";

interface StaleBookmark {
bookmark: string;
prNumber: number;
title: string;
state: "MERGED" | "CLOSED";
}

/**
* Find tracked bookmarks with MERGED or CLOSED PRs that should be cleaned up.
* Prompt user to clean up merged/closed changes.
* Properly reparents children and abandons the change.
*/
function findStaleTrackedBookmarks(engine: Engine): StaleBookmark[] {
const stale: StaleBookmark[] = [];
const trackedBookmarks = engine.getTrackedBookmarks();

for (const bookmark of trackedBookmarks) {
const meta = engine.getMeta(bookmark);
if (meta?.prInfo) {
if (meta.prInfo.state === "MERGED" || meta.prInfo.state === "CLOSED") {
stale.push({
bookmark,
prNumber: meta.prInfo.number,
title: meta.prInfo.title,
state: meta.prInfo.state,
});
}
}
}

return stale;
}

/**
* Prompt user to clean up stale bookmarks (merged/closed PRs).
* This untrracks from arr and deletes the jj bookmark if it exists.
*/
async function promptAndCleanupStale(
stale: StaleBookmark[],
async function promptAndCleanupMerged(
pending: MergedChange[],
engine: Engine,
): Promise<number> {
if (stale.length === 0) return 0;
if (pending.length === 0) return 0;

let cleanedUp = 0;

for (const item of stale) {
for (const item of pending) {
const prLabel = magenta(`PR #${item.prNumber}`);
const branchLabel = dim(`(${item.bookmark})`);
const stateLabel = item.state === "MERGED" ? "merged" : "closed";
const stateLabel = item.reason === "merged" ? "merged" : "closed";

const confirmed = await confirm(
`Clean up ${stateLabel} ${prLabel} ${branchLabel}: ${item.title}?`,
`Clean up ${stateLabel} ${prLabel} ${branchLabel}: ${item.description}?`,
{ default: true },
);

if (confirmed) {
// Untrack from arr
engine.untrack(item.bookmark);

// Delete the jj bookmark if it exists
await deleteBookmark(item.bookmark);

cleanedUp++;
// Reparent children, abandon change, delete bookmark, untrack
const result = await reparentAndCleanup(item, engine);
if (result.ok) {
cleanedUp++;
}
}
}

Expand All @@ -103,10 +70,9 @@ export async function sync(ctx: ArrContext): Promise<void> {
}
}

// Find and prompt to clean up stale bookmarks (merged/closed PRs)
const staleBookmarks = findStaleTrackedBookmarks(ctx.engine);
const cleanedUpCount = await promptAndCleanupStale(
staleBookmarks,
// Find and prompt to clean up merged/closed changes
const cleanedUpCount = await promptAndCleanupMerged(
result.pendingCleanup,
ctx.engine,
);

Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/commands/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { cyan, dim, formatSuccess, indent, message } from "../utils/output";
import { unwrap } from "../utils/run";

export async function track(
bookmark: string | undefined,
target: string | undefined,
ctx: ArrContext,
): Promise<void> {
const result = unwrap(
await trackCmd({
engine: ctx.engine,
bookmark,
target,
}),
);

Expand Down
102 changes: 71 additions & 31 deletions packages/core/src/commands/track.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Engine } from "../engine";
import { getTrunk, list, status } from "../jj";
import { ensureBookmark, findChange, getTrunk, list, status } from "../jj";
import { createError, err, ok, type Result } from "../result";
import { datePrefixedLabel } from "../slugify";
import type { Command } from "./types";

interface TrackResult {
Expand All @@ -10,39 +11,91 @@ interface TrackResult {

interface TrackOptions {
engine: Engine;
/** Bookmark to track. If not provided, uses current working copy's bookmark */
bookmark?: string;
/** Parent branch name. If not provided, auto-detects or prompts */
/**
* Target to track. Can be:
* - A bookmark name (existing)
* - A change ID (will create bookmark from description)
* - A description search (will create bookmark from description)
* - Undefined (uses current change @-)
*/
target?: string;
/** Parent branch name. If not provided, auto-detects */
parent?: string;
}

/**
* Track a bookmark with arr.
* This adds the bookmark to the engine's tracking system.
* Track a change with arr.
* Creates a bookmark if needed, then adds to the engine's tracking system.
*/
export async function track(
options: TrackOptions,
): Promise<Result<TrackResult>> {
const { engine, parent } = options;
let { bookmark } = options;
const { engine, parent, target } = options;

const trunk = await getTrunk();

// If no bookmark provided, get from current change (the parent of WC)
if (!bookmark) {
// Resolve the target to a change
let changeId: string;
let description: string;
let existingBookmark: string | undefined;
let timestamp: Date;

if (!target) {
// No target - use current change (@-)
const statusResult = await status();
if (!statusResult.ok) return statusResult;

const currentBookmark = statusResult.value.parents[0]?.bookmarks[0];
if (!currentBookmark) {
const current = statusResult.value.parents[0];
if (!current) {
return err(createError("INVALID_STATE", "No current change"));
}
changeId = current.changeId;
description = current.description;
existingBookmark = current.bookmarks[0];
timestamp = current.timestamp;
} else {
// Try to find the change by ID, bookmark, or description
const findResult = await findChange(target, { includeBookmarks: true });
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(
"INVALID_STATE",
"No bookmark on current change. Create a bookmark first with jj bookmark create.",
"AMBIGUOUS_REVISION",
`Multiple changes match "${target}". Use a more specific identifier.`,
),
);
}
bookmark = currentBookmark;

const change = findResult.value.change;
changeId = change.changeId;
description = change.description;
existingBookmark = change.bookmarks[0];
timestamp = change.timestamp;
}

// Check if change has no description
if (!description.trim()) {
return err(
createError(
"INVALID_STATE",
"Change has no description. Add a description before tracking.",
),
);
}

// Use existing bookmark or create one from description
let bookmark: string;
if (existingBookmark) {
bookmark = existingBookmark;
} else {
bookmark = datePrefixedLabel(description, timestamp);
await ensureBookmark(bookmark, changeId);
}

// Check if already tracked
Expand All @@ -55,20 +108,11 @@ export async function track(
// Determine parent branch
let parentBranch = parent;
if (!parentBranch) {
// Auto-detect parent from the change's parent
const changeResult = await list({
revset: `bookmarks(exact:"${bookmark}")`,
limit: 1,
});
const changeResult = await list({ revset: changeId, limit: 1 });
if (!changeResult.ok) return changeResult;
if (changeResult.value.length === 0) {
return err(
createError("INVALID_STATE", `Bookmark "${bookmark}" not found`),
);
}

const change = changeResult.value[0];
const parentChangeId = change.parents[0];
const parentChangeId = change?.parents[0];

if (parentChangeId) {
// Check if parent is trunk
Expand All @@ -85,16 +129,12 @@ export async function track(
parentBranch = trunk;
} else {
// Find parent's bookmark
const parentResult = await list({
revset: parentChangeId,
limit: 1,
});
const parentResult = await list({ revset: parentChangeId, limit: 1 });
if (parentResult.ok && parentResult.value.length > 0) {
const parentBookmark = parentResult.value[0].bookmarks[0];
if (parentBookmark && engine.isTracked(parentBookmark)) {
parentBranch = parentBookmark;
} else {
// Parent is not tracked - default to trunk
parentBranch = trunk;
}
} else {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/jj/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { list } from "./list";
export { getLog } from "./log";
export { jjNew } from "./new";
export { push } from "./push";
export { rebase } from "./rebase";
export {
getTrunk,
runJJ,
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/jj/rebase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Result } from "../result";
import { runJJVoid } from "./runner";

interface RebaseOptions {
/** The bookmark or revision to rebase */
source: string;
/** The destination to rebase onto */
destination: string;
/**
* Rebase mode:
* - "branch" (-b): Rebase source and all ancestors not in destination (default)
* - "revision" (-r): Rebase only the source commit, not its ancestors
*/
mode?: "branch" | "revision";
}

/**
* Rebase a bookmark/revision onto a new destination.
*/
export async function rebase(
options: RebaseOptions,
cwd = process.cwd(),
): Promise<Result<void>> {
const flag = options.mode === "revision" ? "-r" : "-b";
return runJJVoid(
["rebase", flag, options.source, "-d", options.destination],
cwd,
);
}
Loading
Loading