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
168 changes: 168 additions & 0 deletions scripts/screenshot/specs/undo-revert-commit.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import * as React from "react";
import { expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { ask } from "@tauri-apps/plugin-dialog";
import {
commitWorkspaceFile,
createTestRepo,
openRepo,
} from "../../../test/utils";
import { render, screen, waitFor, within } from "../../../test/test-utils";
import { Dashboard } from "../../../src/components/Dashboard";
import { getWorkspaces } from "../../../src/lib/api";
import { captureDocument } from "../capture";

const BRANCH_NAME = "feat/undo-revert-demo";

function rowFor(commitId: string): HTMLElement {
const el = document.querySelector(`[data-commit-id="${commitId}"]`);
if (!el) throw new Error(`No row found for commit ${commitId}`);
return el as HTMLElement;
}

// Scenario: the user creates a workspace via the home repo's "Stack" button,
// makes 2 workspace commits, opens the Commits tab, and exercises the new
// "Undo commit" (only valid on the latest commit in the lineage) and
// "Revert commit" (valid on any commit) actions.
it("captures undoing and reverting commits from the Commits tab", async () => {
vi.mocked(ask).mockResolvedValue(true);

const { repoPath } = createTestRepo(false);
openRepo(repoPath);

const user = userEvent.setup();
render(<Dashboard />);

await screen.findByTestId("show-workspace-header");
await user.click(await screen.findByRole("button", { name: "Stack" }));

const createDialog = await screen.findByTestId("modal");
const branchNameInput = within(createDialog).getByLabelText("Branch Name");
await user.type(branchNameInput, BRANCH_NAME);
await user.click(
within(createDialog).getByRole("button", { name: "Create Workspace" }),
);
await waitFor(() => {
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});

const header = await screen.findByTestId("show-workspace-header");
await within(header).findByText(BRANCH_NAME);

const workspace = (await getWorkspaces(repoPath)).find(
(candidate) => candidate.branch_name === BRANCH_NAME,
);
if (!workspace) {
throw new Error(`Expected ${BRANCH_NAME} workspace to exist`);
}

await commitWorkspaceFile(
repoPath,
{ id: workspace.id, path: workspace.workspace_path },
"a.txt",
"content a",
"First commit",
);
await commitWorkspaceFile(
repoPath,
{ id: workspace.id, path: workspace.workspace_path },
"b.txt",
"content b",
"Second commit",
);

await user.click(await screen.findByRole("tab", { name: "Commits" }));
await screen.findByText("Second commit");
await screen.findByText("First commit");

// Expand the newest commit — it should offer "Undo commit".
const secondCommitHeader = await screen.findByText("Second commit");
await user.click(secondCommitHeader);
const secondCommitRowEl = secondCommitHeader.closest(
"[data-commit-id]",
) as HTMLElement;
const secondCommitId = secondCommitRowEl.getAttribute("data-commit-id")!;
await within(rowFor(secondCommitId)).findByRole("button", {
name: "Undo commit",
});

await captureDocument(document, {
name: "undo-revert-commit-01-latest-expanded",
expectations: [
"The Commits tab is active, showing the expanded 'Second commit' row.",
"The action bar for the expanded row includes an 'Undo commit' button alongside 'Edit description', 'Revert commit', 'Move commit', and 'Delete commit'.",
],
});

// Collapse it, then expand the older commit — no "Undo commit" there,
// but "Revert commit" is still available.
await user.click(secondCommitHeader);
const firstCommitHeader = await screen.findByText("First commit");
await user.click(firstCommitHeader);
const firstCommitRowEl = firstCommitHeader.closest(
"[data-commit-id]",
) as HTMLElement;
const firstCommitId = firstCommitRowEl.getAttribute("data-commit-id")!;
await within(rowFor(firstCommitId)).findByRole("button", {
name: "Revert commit",
});
expect(
within(rowFor(firstCommitId)).queryByRole("button", {
name: "Undo commit",
}),
).not.toBeInTheDocument();

await captureDocument(document, {
name: "undo-revert-commit-02-older-expanded-no-undo",
expectations: [
"The expanded 'First commit' row's action bar shows 'Revert commit' but does NOT show an 'Undo commit' button.",
],
});

// Revert the older commit.
await user.click(
within(rowFor(firstCommitId)).getByRole("button", {
name: "Revert commit",
}),
);
await screen.findByText("Commit reverted");
await screen.findByText(/^Revert "First commit"/);

await captureDocument(document, {
name: "undo-revert-commit-03-after-revert",
expectations: [
"A success toast reading 'Commit reverted' is visible.",
"The commit list now shows a new top commit whose title starts with 'Revert \"First commit\"', above 'Second commit' and 'First commit'.",
],
});

// Undo the now-latest commit (the revert commit itself).
const revertCommitHeader = await screen.findByText(
/^Revert "First commit"/,
);
await user.click(revertCommitHeader);
const revertRowEl = revertCommitHeader.closest(
"[data-commit-id]",
) as HTMLElement;
const revertCommitId = revertRowEl.getAttribute("data-commit-id")!;
await user.click(
within(rowFor(revertCommitId)).getByRole("button", {
name: "Undo commit",
}),
);
await screen.findByText("Commit undone");

await waitFor(() => {
expect(
screen.queryByText(/^Revert "First commit"/),
).not.toBeInTheDocument();
});

await captureDocument(document, {
name: "undo-revert-commit-04-after-undo",
expectations: [
"A success toast reading 'Commit undone' is visible.",
"The revert commit is gone from the list, leaving 'Second commit' and 'First commit'.",
],
});
}, 60000);
60 changes: 60 additions & 0 deletions src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,66 @@ pub async fn abandon_commit(
result
}

/// Undo the latest commit in a workspace's own lineage (not the working copy,
/// not a commit on the target branch). Must be undone sequentially from the tip.
#[tauri::command]
pub async fn undo_commit(
repo_path: String,
workspace_id: i64,
commit_change_id: String,
) -> Result<(), String> {
let started_at = Instant::now();
let repo_path_for_task = repo_path.clone();
let commit_change_id_for_task = commit_change_id.clone();
let result = tauri::async_runtime::spawn_blocking(move || {
crate::core::undo_commit(
&repo_path_for_task,
workspace_id,
&commit_change_id_for_task,
)
})
.await
.map_err(|e| format!("Failed to join undo_commit task: {}", e))?;
log::debug!(
"undo_commit(repo_path={}, workspace_id={}, commit_change_id={}) completed in {:?}",
repo_path,
workspace_id,
commit_change_id,
started_at.elapsed()
);
result
}

/// Revert a commit by creating a new commit that reverses its changes on top
/// of the workspace's current tip. Can target any commit except the working copy.
#[tauri::command]
pub async fn revert_commit(
repo_path: String,
workspace_id: i64,
commit_change_id: String,
) -> Result<(), String> {
let started_at = Instant::now();
let repo_path_for_task = repo_path.clone();
let commit_change_id_for_task = commit_change_id.clone();
let result = tauri::async_runtime::spawn_blocking(move || {
crate::core::revert_commit(
&repo_path_for_task,
workspace_id,
&commit_change_id_for_task,
)
})
.await
.map_err(|e| format!("Failed to join revert_commit task: {}", e))?;
log::debug!(
"revert_commit(repo_path={}, workspace_id={}, commit_change_id={}) completed in {:?}",
repo_path,
workspace_id,
commit_change_id,
started_at.elapsed()
);
result
}

#[tauri::command]
pub async fn rebase_home_repo_branch(
repo_path: String,
Expand Down
83 changes: 83 additions & 0 deletions src-tauri/src/core/commits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,89 @@ pub fn undo_repo_operation(
.map_err(|e| format!("Failed to undo operation: {}", e))
}

/// Undoes the latest commit in a workspace's own lineage by change-id.
///
/// Only the workspace's current tip commit can be undone — not the working
/// copy, and not a commit inherited from the target branch. To undo an older
/// commit, undo newer commits first, one at a time.
///
/// # Arguments
/// * `repo_path` - Path to the repository root
/// * `workspace_id` - ID of the workspace that owns the commit
/// * `commit_change_id` - The short change-id of the commit to undo
///
/// # Returns
/// `Ok(())` on success, or an error string.
pub fn undo_commit(
repo_path: &str,
workspace_id: i64,
commit_change_id: &str,
) -> Result<(), String> {
let workspace = local_db::get_workspace_by_id(repo_path, workspace_id)
.map_err(|e| format!("Failed to get workspace: {}", e))?
.ok_or_else(|| format!("Workspace not found: {}", workspace_id))?;

let workspace_dir = Path::new(repo_path)
.join(".treq")
.join("workspaces")
.join(&workspace.workspace_path);
let workspace_dir_str = workspace_dir
.to_str()
.ok_or("Failed to convert workspace path to string")?;

let default_branch = jj::get_default_branch(repo_path)
.map_err(|e| format!("Failed to resolve default branch: {}", e))?;
let target_branch = workspace
.target_branch
.as_deref()
.unwrap_or(&default_branch);

jj::jj_undo_commit(workspace_dir_str, target_branch, commit_change_id)
.map_err(|e| format!("Failed to undo commit: {}", e))?;

jj::update_stale_workspace(workspace_dir_str)
.map_err(|e| format!("Failed to update workspace working copy: {}", e))?;

Ok(())
}

/// Reverts a specific commit by change-id, creating a new commit that reverses
/// its changes on top of the workspace's current tip. Can target any real
/// commit reachable from the workspace except the working-copy commit itself.
///
/// # Arguments
/// * `repo_path` - Path to the repository root
/// * `workspace_id` - ID of the workspace that owns the commit
/// * `commit_change_id` - The short change-id of the commit to revert
///
/// # Returns
/// `Ok(())` on success, or an error string.
pub fn revert_commit(
repo_path: &str,
workspace_id: i64,
commit_change_id: &str,
) -> Result<(), String> {
let workspace = local_db::get_workspace_by_id(repo_path, workspace_id)
.map_err(|e| format!("Failed to get workspace: {}", e))?
.ok_or_else(|| format!("Workspace not found: {}", workspace_id))?;

let workspace_dir = Path::new(repo_path)
.join(".treq")
.join("workspaces")
.join(&workspace.workspace_path);
let workspace_dir_str = workspace_dir
.to_str()
.ok_or("Failed to convert workspace path to string")?;

jj::jj_revert_commit(workspace_dir_str, commit_change_id)
.map_err(|e| format!("Failed to revert commit: {}", e))?;

jj::update_stale_workspace(workspace_dir_str)
.map_err(|e| format!("Failed to update workspace working copy: {}", e))?;

Ok(())
}

/// Returns the full (multi-line) description of a specific commit.
///
/// # Arguments
Expand Down
Loading
Loading