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
129 changes: 93 additions & 36 deletions apps/cli/src/commands/log.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { hasResolvedConflict } from "@array/core/commands/resolve";
import type { ArrContext, Engine } from "@array/core/engine";
import { getCurrentGitBranch } from "@array/core/git/status";
import { getBookmarkTracking, runJJ } from "@array/core/jj";
import { getDiffStats, runJJ } from "@array/core/jj";
import { COMMANDS } from "../registry";
import {
arr,
Expand Down Expand Up @@ -107,8 +108,11 @@ export async function log(

// Build enhancement data
const prInfoMap = buildPRInfoMap(engine, trackedBookmarks);
const modifiedBookmarks = await getModifiedBookmarks(cwd);
const modifiedBookmarks = await getModifiedBookmarks(trackedBookmarks, cwd);
const behindTrunkChanges = await getBehindTrunkChanges(cwd);
const wcParentBookmark = await getWCParentBookmark(trackedBookmarks, cwd);
const resolvedConflictResult = await hasResolvedConflict(cwd);
const hasResolved = resolvedConflictResult.ok && resolvedConflictResult.value;

// Check if empty state (just on trunk with empty WC)
const lines = result.value.stdout.split("\n");
Expand All @@ -131,6 +135,8 @@ export async function log(
modifiedBookmarks,
trackedBookmarks,
behindTrunkChanges,
wcParentBookmark,
hasResolved,
);
message(output);

Expand All @@ -155,6 +161,8 @@ function renderEnhancedOutput(
modifiedBookmarks: Set<string>,
trackedBookmarks: string[],
behindTrunkChanges: Set<string>,
wcParentBookmark: string | null,
hasResolvedConflict: boolean,
): string {
const lines = rawOutput.split("\n");
const output: string[] = [];
Expand All @@ -170,26 +178,6 @@ function renderEnhancedOutput(
let _currentIsEmpty = false; // Whether the change has no file modifications
let pendingHints: string[] = []; // Buffer hints to output after COMMIT

// Find WC parent bookmark for modify hint by looking at the next CHANGE after WC
let wcParentBookmark: string | null = null;
let foundWC = false;
for (const line of lines) {
if (line.includes("@") && line.includes("CHANGE:")) {
foundWC = true;
continue;
}
if (foundWC && line.includes("CHANGE:")) {
// This is the first change after WC - check if it has a tracked bookmark
const match = line.match(/CHANGE:[^|]+\|[^|]*\|[^|]*\|[^|]*\|([^|]*)\|/);
if (match) {
const bookmarks = parseBookmarks(match[1]);
wcParentBookmark =
bookmarks.find((b) => trackedBookmarks.includes(b)) || null;
}
break;
}
}

for (const line of lines) {
// Skip the ~ line at end
if (line.trim() === "~") continue;
Expand Down Expand Up @@ -246,6 +234,11 @@ function renderEnhancedOutput(
currentIsWorkingCopy = isWorkingCopy;
_currentIsEmpty = isEmpty;

// Check if this is a merged or closed PR
const prInfo = currentBookmark ? prInfoMap.get(currentBookmark) : null;
const isMerged = prInfo?.state === "MERGED";
const isClosed = prInfo?.state === "CLOSED";

// Skip rendering fork points - just keep graph lines
if (currentIsForkPoint) {
// Only output the graph connector line
Expand All @@ -261,6 +254,12 @@ function renderEnhancedOutput(
let styledPrefix = graphPrefix;
if (isWorkingCopy) {
styledPrefix = graphPrefix.replace("@", green("◉"));
} else if (isMerged) {
// Merged PRs get a filled marker to indicate they're done
styledPrefix = graphPrefix.replace(/[◆○]/g, magenta("◆"));
} else if (isClosed) {
// Closed PRs get a red X marker
styledPrefix = graphPrefix.replace(/[◆○]/g, red("×"));
} else if (graphPrefix.includes("◆")) {
styledPrefix = graphPrefix.replace("◆", "◯");
} else if (graphPrefix.includes("○")) {
Expand All @@ -280,8 +279,11 @@ function renderEnhancedOutput(
const shortId = formatChangeId(changeId, changeIdPrefix);

const badges: string[] = [];
if (currentIsBehindTrunk) badges.push(yellow("behind trunk"));
if (currentIsModified) badges.push(yellow("local changes"));
if (isMerged) badges.push(magenta("merged"));
else if (isClosed) badges.push(red("closed"));
else if (currentIsBehindTrunk) badges.push(yellow("behind trunk"));
if (currentIsModified && !isMerged && !isClosed)
badges.push(yellow("local changes"));
if (hasConflict) badges.push(yellow("conflicts"));
const badgeStr =
badges.length > 0
Expand Down Expand Up @@ -329,7 +331,6 @@ function renderEnhancedOutput(
) {
const prInfo = prInfoMap.get(bookmark);
if (prInfo) {
// Don't show merged/closed PRs that are now part of trunk
if (prInfo.state === "OPEN") {
output.push(`${graphPrefix}${formatPRLine(prInfo)}`);
output.push(`${graphPrefix}${cyan(prInfo.url)}`);
Expand All @@ -338,6 +339,18 @@ function renderEnhancedOutput(
`${graphPrefix}${arr(COMMANDS.submit)} ${dim("to push local changes")}`,
);
}
} else if (prInfo.state === "MERGED") {
output.push(`${graphPrefix}${formatPRLine(prInfo)}`);
output.push(`${graphPrefix}${cyan(prInfo.url)}`);
pendingHints.push(
`${graphPrefix}${arr(COMMANDS.sync)} ${dim("to clean up merged changes")}`,
);
} else if (prInfo.state === "CLOSED") {
output.push(`${graphPrefix}${formatPRLine(prInfo)}`);
output.push(`${graphPrefix}${cyan(prInfo.url)}`);
pendingHints.push(
`${graphPrefix}${arr(COMMANDS.sync)} ${dim("to clean up closed PR")}`,
);
}
} else {
output.push(`${graphPrefix}${dim("Not submitted")}`);
Expand Down Expand Up @@ -372,13 +385,20 @@ function renderEnhancedOutput(
// Add hints for WC without a bookmark (whether empty or with changes)
if (currentIsWorkingCopy && !currentBookmark) {
const hintPrefix = "│ ";
pendingHints.push(
`${hintPrefix}${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as new change")}`,
);
if (wcParentBookmark) {
if (hasResolvedConflict) {
// Show resolve hint when conflict has been resolved in WC
pendingHints.push(
`${hintPrefix}${arr(COMMANDS.modify)} ${dim(`to update ${wcParentBookmark}`)}`,
`${hintPrefix}${arr(COMMANDS.resolve)} ${dim("to apply conflict resolution")}`,
);
} else {
pendingHints.push(
`${hintPrefix}${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as new change")}`,
);
if (wcParentBookmark) {
pendingHints.push(
`${hintPrefix}${arr(COMMANDS.modify)} ${dim(`to update ${wcParentBookmark}`)}`,
);
}
}
}

Expand Down Expand Up @@ -441,16 +461,26 @@ function buildPRInfoMap(
return prInfoMap;
}

async function getModifiedBookmarks(cwd: string): Promise<Set<string>> {
const trackingResult = await getBookmarkTracking(cwd);
async function getModifiedBookmarks(
trackedBookmarks: string[],
cwd: string,
): Promise<Set<string>> {
const modifiedBookmarks = new Set<string>();
if (trackingResult.ok) {
for (const t of trackingResult.value) {
if (t.aheadCount > 0) {
modifiedBookmarks.add(t.name);

for (const bookmark of trackedBookmarks) {
const diffResult = await getDiffStats(
`bookmarks(exact:"${bookmark}")`,
{ fromBookmark: bookmark },
cwd,
);
if (diffResult.ok) {
const { filesChanged, insertions, deletions } = diffResult.value;
if (filesChanged > 0 || insertions > 0 || deletions > 0) {
modifiedBookmarks.add(bookmark);
}
}
}

return modifiedBookmarks;
}

Expand Down Expand Up @@ -482,6 +512,33 @@ async function getBehindTrunkChanges(cwd: string): Promise<Set<string>> {
return behindChanges;
}

/**
* Get the bookmark of the WC's parent (@-) if it's tracked.
*/
async function getWCParentBookmark(
trackedBookmarks: string[],
cwd: string,
): Promise<string | null> {
const result = await runJJ(
[
"log",
"-r",
"@-",
"--no-graph",
"-T",
'local_bookmarks.map(|b| b.name()).join(",")',
],
cwd,
);
if (!result.ok) return null;

const bookmarks = result.value.stdout
.trim()
.split(",")
.filter((b) => b.trim());
return bookmarks.find((b) => trackedBookmarks.includes(b)) || null;
}

function formatChangeId(changeId: string, prefix: string): string {
const short = changeId.slice(0, 8);
if (prefix && short.startsWith(prefix)) {
Expand Down
10 changes: 10 additions & 0 deletions apps/cli/src/commands/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { resolve as coreResolve } from "@array/core/commands/resolve";
import { green, message } from "../utils/output";
import { unwrap } from "../utils/run";

export async function resolve(): Promise<void> {
const result = unwrap(await coreResolve());
const label =
result.bookmark || result.description || result.changeId.slice(0, 8);
message(`Resolved conflicts in ${green(label)}`);
}
10 changes: 8 additions & 2 deletions apps/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { hasResolvedConflict } from "@array/core/commands/resolve";
import { status as statusCmd } from "@array/core/commands/status";
import { COMMANDS } from "../registry";
import {
arr,
blank,
cmd,
cyan,
dim,
formatChangeId,
Expand All @@ -21,6 +21,8 @@ import { unwrap } from "../utils/run";
export async function status(): Promise<void> {
const { info, stats } = unwrap(await statusCmd());
const statsStr = stats ? ` ${formatDiffStats(stats)}` : "";
const resolvedResult = await hasResolvedConflict();
const hasResolved = resolvedResult.ok && resolvedResult.value;

// Check if on main with no stack above (fresh start)
const isOnMainFresh =
Expand Down Expand Up @@ -82,7 +84,11 @@ export async function status(): Promise<void> {
} else {
switch (action) {
case "continue":
message(`Fix conflicts, then run ${cmd("jj squash")}`);
if (hasResolved) {
message(`${arr(COMMANDS.resolve)} to apply conflict resolution`);
} else {
message(`Fix conflicts, then run ${arr(COMMANDS.resolve)}`);
}
break;
case "create":
if (reason === "unsaved") {
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { downCommand } from "@array/core/commands/down";
import { getCommand } from "@array/core/commands/get";
import { mergeCommand } from "@array/core/commands/merge";
import { modifyCommand } from "@array/core/commands/modify";
import { resolveCommand } from "@array/core/commands/resolve";
import { restackCommand } from "@array/core/commands/restack";
import { squashCommand } from "@array/core/commands/squash";
import { statusCommand } from "@array/core/commands/status";
Expand Down Expand Up @@ -33,6 +34,7 @@ import { init, meta as initMeta } from "./commands/init";
import { log } from "./commands/log";
import { merge } from "./commands/merge";
import { modify } from "./commands/modify";
import { resolve } from "./commands/resolve";
import { restack } from "./commands/restack";
import { squash } from "./commands/squash";
import { status } from "./commands/status";
Expand Down Expand Up @@ -99,6 +101,7 @@ export const COMMANDS = {
status: statusCommand.meta,
delete: deleteCommand.meta,
modify: modifyCommand.meta,
resolve: resolveCommand.meta,
squash: squashCommand.meta,
merge: mergeCommand.meta,
undo: undoCommand.meta,
Expand Down Expand Up @@ -130,6 +133,7 @@ export const HANDLERS: Record<string, CommandHandler> = {
delete: (p, ctx) =>
deleteChange(p.args[0], ctx!, { yes: !!p.flags.yes || !!p.flags.y }),
modify: () => modify(),
resolve: () => resolve(),
squash: (p, ctx) => squash(p.args[0], ctx!),
merge: (p, ctx) => merge(p.flags, ctx!),
undo: () => undo(),
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/utils/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function formatSuccess(message: string): string {
*
* Messages:
* - editing: "Editing branch-name"
* - on-top: "Now above branch-name"
* - on-top: "Now working on branch-name"
* - on-trunk: "Starting fresh on main"
*/
export function printNavResult(nav: NavigationResult): void {
Expand All @@ -91,7 +91,7 @@ export function printNavResult(nav: NavigationResult): void {
console.log(`Editing ${green(label)}`);
break;
case "on-top":
console.log(`Now above ${green(label)}`);
console.log(`Now working on ${green(label)}`);
break;
case "on-trunk":
console.log(`Starting fresh on ${cyan(label)}`);
Expand Down
32 changes: 17 additions & 15 deletions packages/core/src/commands/bottom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,30 @@ import type { Command } from "./types";
* Navigate to the bottom of the current stack.
*/
export async function bottom(): Promise<Result<NavigationResult>> {
// Find roots of the current stack (changes between trunk and @)
const rootsResult = await list({ revset: "roots(trunk()..@)" });
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) {
// Check if we're already at the bottom
const statusResult = await status();
if (!statusResult.ok) return statusResult;

// If we're on an empty undescribed WC directly on trunk, that's the bottom
const wc = statusResult.value.workingCopy;
if (wc.isEmpty && wc.description.trim() === "") {
return err(
createError("NAVIGATION_FAILED", "Already at bottom of stack"),
);
}

return err(createError("NAVIGATION_FAILED", "Already at bottom of stack"));
}

Expand All @@ -41,7 +44,6 @@ export async function bottom(): Promise<Result<NavigationResult>> {
);
}

// Navigate to the root (handles immutability correctly)
return navigateTo(roots[0]);
}

Expand Down
Loading
Loading