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
66 changes: 39 additions & 27 deletions apps/cli/src/commands/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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 =
Expand All @@ -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) {
Expand All @@ -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("◆", "◯");
Expand All @@ -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)}`);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
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: "Working on top of branch-name"
* - on-top: "Now above 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(`Working on top of ${green(label)}`);
console.log(`Now above ${green(label)}`);
break;
case "on-trunk":
console.log(`Starting fresh on ${cyan(label)}`);
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/commands/create.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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() !== "") {
Expand Down
70 changes: 41 additions & 29 deletions packages/core/src/commands/restack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<string[]>> {
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<Result<{ restacked: number }>> {
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",
Expand All @@ -74,7 +86,7 @@ async function restackTracked(
if (!result.ok) return result;
}

return ok({ restacked: behind.length });
return ok({ restacked: roots.length });
}

/**
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@ export async function status(): Promise<Result<StatusResult>> {
};

// 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 });
Expand Down
67 changes: 47 additions & 20 deletions packages/core/src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,42 +127,69 @@ export async function sync(options: SyncOptions): Promise<Result<SyncResult>> {
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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/jj/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function getDiffStats(
"diff",
"--from",
`${options.fromBookmark}@origin`,
"-r",
"--to",
revision,
"--stat",
],
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type JJErrorCode =
| "MERGE_BLOCKED"
| "ALREADY_MERGED"
| "NOT_FOUND"
| "EMPTY_CHANGE"
| "UNKNOWN";

export function createError(
Expand Down
Loading