diff --git a/apps/cli/src/commands/log.ts b/apps/cli/src/commands/log.ts index bf487923..5e63d638 100644 --- a/apps/cli/src/commands/log.ts +++ b/apps/cli/src/commands/log.ts @@ -166,19 +166,26 @@ function renderEnhancedOutput( let currentIsTrunk = false; let currentIsForkPoint = false; // Immutable commit included only for graph connectivity let currentIsBehindTrunk = false; // Mutable commit whose parent is not current trunk + let currentIsWorkingCopy = false; // Whether this is the @ commit + let _currentIsEmpty = false; // Whether the change has no file modifications let pendingHints: string[] = []; // Buffer hints to output after COMMIT - // Check if WC parent is a tracked bookmark (for modify hint) - // We only show "arr modify" if WC is on top of a tracked change - const wcParentBookmark: string | null = null; + // 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("HINT:empty")) { - // WC is empty - check if parent is a tracked bookmark by looking at graph structure - // The parent in jj graph is indicated by the line connecting @ to the next commit - // But we can't reliably parse this from output, so check tracked bookmarks - // If there's exactly one tracked bookmark that's a direct parent of @, use it - // For now, we'll be conservative and not show modify hint unless we're certain - // TODO: Query jj for @- to get actual parent + 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; } } @@ -223,6 +230,7 @@ function renderEnhancedOutput( const isEmpty = emptyFlag === "1"; const isImmutable = immutableFlag === "1"; const hasConflict = conflictFlag === "1"; + const isWorkingCopy = graphPrefix.includes("@"); // Update context for subsequent lines (TIME, PR, COMMIT) currentBookmark = @@ -235,6 +243,8 @@ function renderEnhancedOutput( // Fork point: immutable commit that's not trunk (included for graph connectivity) currentIsForkPoint = isImmutable && !isTrunk; currentIsBehindTrunk = behindTrunkChanges.has(changeId); + currentIsWorkingCopy = isWorkingCopy; + _currentIsEmpty = isEmpty; // Skip rendering fork points - just keep graph lines if (currentIsForkPoint) { @@ -249,7 +259,7 @@ function renderEnhancedOutput( // Replace the marker in graphPrefix with our styled version // jj uses: @ for WC, ○ for mutable, ◆ for immutable let styledPrefix = graphPrefix; - if (graphPrefix.includes("@")) { + if (isWorkingCopy) { styledPrefix = graphPrefix.replace("@", green("◉")); } else if (graphPrefix.includes("◆")) { styledPrefix = graphPrefix.replace("◆", "◯"); @@ -258,8 +268,8 @@ function renderEnhancedOutput( } // Build the label - if (isEmpty && !description && !isImmutable) { - // Empty WC + if (isWorkingCopy && !currentBookmark) { + // Working copy without a bookmark - show "(working copy)" output.push(`${styledPrefix}${blue("(working copy)")}`); } else if (isTrunk) { output.push(`${styledPrefix}${blue(trunkName)}`); @@ -298,20 +308,8 @@ function renderEnhancedOutput( } case "HINT:": { - if (data === "empty") { - // Buffer hints to output after COMMIT line - // Use a clean "│ " prefix, not the graph prefix which may have ~ terminators - const hintPrefix = "│ "; - pendingHints.push( - `${hintPrefix}${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as new change")}`, - ); - // Only show modify hint if WC parent is a tracked bookmark - if (wcParentBookmark) { - pendingHints.push( - `${hintPrefix}${arr(COMMANDS.modify)} ${dim(`to update ${wcParentBookmark}`)}`, - ); - } - } + // Hints are now handled in COMMIT case for all WC states + // This case is kept for potential future use break; } @@ -370,6 +368,20 @@ function renderEnhancedOutput( output.push( `${prefix}${commitIdFormatted} ${dim(`- ${description || "(no description)"}`)}`, ); + + // 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) { + pendingHints.push( + `${hintPrefix}${arr(COMMANDS.modify)} ${dim(`to update ${wcParentBookmark}`)}`, + ); + } + } + // Output any pending hints after commit if (pendingHints.length > 0) { for (const hint of pendingHints) { diff --git a/apps/cli/src/utils/output.ts b/apps/cli/src/utils/output.ts index 1931d6b1..875ab784 100644 --- a/apps/cli/src/utils/output.ts +++ b/apps/cli/src/utils/output.ts @@ -80,7 +80,7 @@ export function formatSuccess(message: string): string { * * Messages: * - editing: "Editing branch-name" - * - on-top: "Working on top of branch-name" + * - on-top: "Now above branch-name" * - on-trunk: "Starting fresh on main" */ export function printNavResult(nav: NavigationResult): void { @@ -91,7 +91,7 @@ export function printNavResult(nav: NavigationResult): void { console.log(`Editing ${green(label)}`); break; case "on-top": - console.log(`Working on top of ${green(label)}`); + console.log(`Now above ${green(label)}`); break; case "on-trunk": console.log(`Starting fresh on ${cyan(label)}`); diff --git a/packages/core/src/commands/create.ts b/packages/core/src/commands/create.ts index 9e7c5dcb..9f34d83c 100644 --- a/packages/core/src/commands/create.ts +++ b/packages/core/src/commands/create.ts @@ -1,7 +1,7 @@ import { resolveBookmarkConflict } from "../bookmark-utils"; import type { Engine } from "../engine"; import { ensureBookmark, runJJ, status } from "../jj"; -import { ok, type Result } from "../result"; +import { createError, err, ok, type Result } from "../result"; import { datePrefixedLabel } from "../slugify"; import type { Command } from "./types"; @@ -39,6 +39,18 @@ export async function create( 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.", + ), + ); + } + let createdChangeId: string; if (wc.description.trim() !== "") { diff --git a/packages/core/src/commands/restack.ts b/packages/core/src/commands/restack.ts index db9a6b68..64e51f0b 100644 --- a/packages/core/src/commands/restack.ts +++ b/packages/core/src/commands/restack.ts @@ -18,52 +18,64 @@ interface RestackOptions { } /** - * Find tracked bookmarks that are behind trunk (not based on current trunk tip). + * Find root bookmarks that are behind trunk. + * Roots are tracked bookmarks whose parent is NOT another tracked bookmark. + * We only rebase roots - descendants will follow automatically. */ -async function getBookmarksBehindTrunk( +async function getRootBookmarksBehindTrunk( trackedBookmarks: string[], ): Promise> { if (trackedBookmarks.length === 0) { return ok([]); } - const behindBookmarks: string[] = []; - - for (const bookmark of trackedBookmarks) { - // Check if this bookmark exists and is not a descendant of trunk - const result = await runJJ([ - "log", - "-r", - `bookmarks(exact:"${bookmark}") & mutable() ~ trunk()::`, - "--no-graph", - "-T", - `change_id ++ "\\n"`, - ]); - - if (result.ok && result.value.stdout.trim()) { - behindBookmarks.push(bookmark); - } - } - - return ok(behindBookmarks); + const bookmarkRevsets = trackedBookmarks + .map((b) => `bookmarks(exact:"${b}")`) + .join(" | "); + + // Find roots of tracked bookmarks that are behind trunk + // roots(X) gives commits in X with no ancestors also in X + // ~ trunk():: filters to only those not already on trunk + const rootsRevset = `roots((${bookmarkRevsets}) & mutable()) ~ trunk()::`; + + const result = await runJJ([ + "log", + "-r", + rootsRevset, + "--no-graph", + "-T", + 'local_bookmarks.map(|b| b.name()).join(",") ++ "\\n"', + ]); + + if (!result.ok) return result; + + const rootBookmarks = result.value.stdout + .trim() + .split("\n") + .filter((line) => line.trim()) + .flatMap((line) => line.split(",").filter((b) => b.trim())) + .filter((b) => trackedBookmarks.includes(b)); + + return ok(rootBookmarks); } /** - * Rebase tracked bookmarks that are behind trunk. + * Rebase root tracked bookmarks that are behind trunk. + * Only rebases roots - descendants follow automatically. */ async function restackTracked( trackedBookmarks: string[], ): Promise> { - const behindResult = await getBookmarksBehindTrunk(trackedBookmarks); - if (!behindResult.ok) return behindResult; + const rootsResult = await getRootBookmarksBehindTrunk(trackedBookmarks); + if (!rootsResult.ok) return rootsResult; - const behind = behindResult.value; - if (behind.length === 0) { + const roots = rootsResult.value; + if (roots.length === 0) { return ok({ restacked: 0 }); } - // Rebase each behind bookmark onto trunk - for (const bookmark of behind) { + // Rebase each root bookmark onto trunk - descendants will follow + for (const bookmark of roots) { const result = await runJJWithMutableConfigVoid([ "rebase", "-b", @@ -74,7 +86,7 @@ async function restackTracked( if (!result.ok) return result; } - return ok({ restacked: behind.length }); + return ok({ restacked: roots.length }); } /** diff --git a/packages/core/src/commands/status.ts b/packages/core/src/commands/status.ts index 4d2957f6..ee9f20df 100644 --- a/packages/core/src/commands/status.ts +++ b/packages/core/src/commands/status.ts @@ -137,7 +137,11 @@ export async function status(): Promise> { }; // Get diff stats for current change - const statsResult = await getDiffStats("@"); + // If on a bookmark with origin, show diff since last push; otherwise show full commit diff + const currentBookmark = wc.bookmarks[0]; + const statsResult = await getDiffStats("@", { + fromBookmark: currentBookmark, + }); const stats = statsResult.ok ? statsResult.value : null; return ok({ info, stats }); diff --git a/packages/core/src/commands/sync.ts b/packages/core/src/commands/sync.ts index 5ff3b145..385cf7f3 100644 --- a/packages/core/src/commands/sync.ts +++ b/packages/core/src/commands/sync.ts @@ -127,42 +127,69 @@ export async function sync(options: SyncOptions): Promise> { const trunk = await getTrunk(); await runJJ(["bookmark", "set", trunk, "-r", `${trunk}@origin`]); - // Rebase WC onto new trunk if it's behind (empty WC on old trunk ancestor) - await runJJWithMutableConfigVoid(["rebase", "-r", "@", "-d", "trunk()"]); - // Rebase only tracked bookmarks onto trunk (not all mutable commits) // This prevents rebasing unrelated orphaned commits from the repo history const trackedBookmarks = engine.getTrackedBookmarks(); let rebaseOk = true; let rebaseError: string | undefined; - for (const bookmark of trackedBookmarks) { - // Only rebase if bookmark exists and is mutable - const checkResult = await runJJ([ + // Build revset for all tracked bookmarks + if (trackedBookmarks.length > 0) { + const bookmarkRevsets = trackedBookmarks + .map((b) => `bookmarks(exact:"${b}")`) + .join(" | "); + + // Find roots of tracked bookmarks - those whose parent is NOT another tracked bookmark + // roots(X) gives us commits in X that have no ancestors also in X + const rootsRevset = `roots((${bookmarkRevsets}) & mutable())`; + + const rootsResult = await runJJ([ "log", "-r", - `bookmarks(exact:"${bookmark}") & mutable()`, + rootsRevset, "--no-graph", "-T", - "change_id", + 'local_bookmarks.map(|b| b.name()).join(",") ++ "\\n"', ]); - if (checkResult.ok && checkResult.value.stdout.trim()) { - const result = await runJJWithMutableConfigVoid([ - "rebase", - "-b", - bookmark, - "-d", - "trunk()", - ]); - if (!result.ok) { - rebaseOk = false; - rebaseError = result.error.message; - break; + if (rootsResult.ok) { + const rootBookmarks = rootsResult.value.stdout + .trim() + .split("\n") + .filter((line) => line.trim()) + .flatMap((line) => line.split(",").filter((b) => b.trim())); + + // Only rebase root bookmarks - descendants will follow + for (const bookmark of rootBookmarks) { + if (!trackedBookmarks.includes(bookmark)) continue; + + const result = await runJJWithMutableConfigVoid([ + "rebase", + "-b", + bookmark, + "-d", + "trunk()", + ]); + if (!result.ok) { + rebaseOk = false; + rebaseError = result.error.message; + break; + } } } } + // Rebase WC onto trunk if it's not on a tracked bookmark + // (If WC is on a tracked bookmark, it was already rebased above) + const wcStatusResult = await status(); + if (wcStatusResult.ok) { + const wcBookmarks = wcStatusResult.value.workingCopy.bookmarks; + const wcOnTracked = wcBookmarks.some((b) => trackedBookmarks.includes(b)); + if (!wcOnTracked) { + await runJJWithMutableConfigVoid(["rebase", "-r", "@", "-d", "trunk()"]); + } + } + // Check for conflicts let hasConflicts = false; if (rebaseOk) { diff --git a/packages/core/src/jj/diff.ts b/packages/core/src/jj/diff.ts index e6ed1483..04c97336 100644 --- a/packages/core/src/jj/diff.ts +++ b/packages/core/src/jj/diff.ts @@ -36,7 +36,7 @@ export async function getDiffStats( "diff", "--from", `${options.fromBookmark}@origin`, - "-r", + "--to", revision, "--stat", ], diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts index a6d5ad90..66ce7c65 100644 --- a/packages/core/src/result.ts +++ b/packages/core/src/result.ts @@ -32,6 +32,7 @@ export type JJErrorCode = | "MERGE_BLOCKED" | "ALREADY_MERGED" | "NOT_FOUND" + | "EMPTY_CHANGE" | "UNKNOWN"; export function createError(