a very good jj gui
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Sprint 2: add squash and rebase mutation commands

+489 -3
+224 -1
apps/desktop/src-tauri/src/lib.rs
··· 628 628 } 629 629 630 630 #[tauri::command] 631 + async fn jj_squash(repo_path: String, change_id: String) -> Result<MutationResult, String> { 632 + let path = Path::new(&repo_path); 633 + let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 634 + jj_repo 635 + .squash_revision(&change_id) 636 + .map_err(|e| format!("Failed to squash revision: {}", e)) 637 + } 638 + 639 + #[tauri::command] 640 + async fn jj_rebase( 641 + repo_path: String, 642 + source_change_id: String, 643 + destination_change_id: String, 644 + ) -> Result<MutationResult, String> { 645 + let path = Path::new(&repo_path); 646 + let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 647 + jj_repo 648 + .rebase_revision(&source_change_id, &destination_change_id) 649 + .map_err(|e| format!("Failed to rebase revision: {}", e)) 650 + } 651 + 652 + #[tauri::command] 631 653 async fn jj_git_fetch(repo_path: String, remote: Option<String>) -> Result<MutationResult, String> { 632 654 let path = Path::new(&repo_path); 633 655 let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; ··· 923 945 jj_edit, 924 946 jj_abandon, 925 947 jj_describe, 948 + jj_squash, 949 + jj_rebase, 926 950 jj_git_fetch, 927 951 jj_git_push, 928 952 get_operations, ··· 1051 1075 create_conflicted_working_copy(&repo_path); 1052 1076 1053 1077 let status = repo::status::fetch_status(&repo_path).expect("Failed to fetch status"); 1054 - assert!(status.has_conflict, "Working copy status should report conflict"); 1078 + assert!( 1079 + status.has_conflict, 1080 + "Working copy status should report conflict" 1081 + ); 1055 1082 } 1056 1083 1057 1084 #[test] ··· 1473 1500 .get_commit(&change_id) 1474 1501 .expect("Failed to load described commit"); 1475 1502 assert_eq!(described_commit.description(), ""); 1503 + 1504 + drop(temp_dir); 1505 + } 1506 + 1507 + #[test] 1508 + fn test_squash_revision_into_parent() { 1509 + let (temp_dir, repo_path) = create_test_repo(); 1510 + 1511 + let file_path = repo_path.join("squash.txt"); 1512 + fs::write(&file_path, "base\n").expect("Failed to write base file"); 1513 + snapshot_working_copy(&repo_path); 1514 + 1515 + let parent_change_id = get_wc_change_id(&repo_path); 1516 + 1517 + let new_status = Command::new("jj") 1518 + .args(["new"]) 1519 + .current_dir(&repo_path) 1520 + .status() 1521 + .expect("Failed to create child revision for squash"); 1522 + assert!(new_status.success(), "jj new failed for squash setup"); 1523 + 1524 + fs::write(&file_path, "base\nchild\n").expect("Failed to update squash file"); 1525 + snapshot_working_copy(&repo_path); 1526 + 1527 + let source_change_id = get_wc_change_id(&repo_path); 1528 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1529 + 1530 + let result = jj_repo 1531 + .squash_revision(&source_change_id) 1532 + .expect("squash_revision should succeed"); 1533 + 1534 + assert!( 1535 + !result.operation_id.is_empty(), 1536 + "squash should return operation_id" 1537 + ); 1538 + let current_wc_change_id = get_wc_change_id(&repo_path); 1539 + assert_ne!( 1540 + current_wc_change_id, source_change_id, 1541 + "source revision should no longer be the working copy after squash" 1542 + ); 1543 + 1544 + let source_lookup = jj_repo.get_commit(&source_change_id); 1545 + assert!( 1546 + source_lookup.is_err(), 1547 + "squashed source revision should no longer be directly addressable" 1548 + ); 1549 + 1550 + let squashed_parent = jj_repo 1551 + .get_commit(&parent_change_id) 1552 + .expect("Failed to load squashed parent commit"); 1553 + let content = jj_repo 1554 + .get_file_content(&squashed_parent, "squash.txt") 1555 + .expect("Failed to load squashed file content"); 1556 + assert_eq!( 1557 + String::from_utf8(content).expect("File content should be utf-8"), 1558 + "base\nchild\n" 1559 + ); 1560 + 1561 + drop(temp_dir); 1562 + } 1563 + 1564 + #[test] 1565 + fn test_squash_root_rejected() { 1566 + let (temp_dir, repo_path) = create_test_repo(); 1567 + 1568 + let root_change_id = get_root_change_id(&repo_path); 1569 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1570 + 1571 + let error = jj_repo 1572 + .squash_revision(&root_change_id) 1573 + .expect_err("Squashing root commit should fail"); 1574 + assert!( 1575 + error.to_string().contains("no parent"), 1576 + "Error should mention missing parent" 1577 + ); 1578 + 1579 + drop(temp_dir); 1580 + } 1581 + 1582 + #[test] 1583 + fn test_rebase_revision_onto_target() { 1584 + let (temp_dir, repo_path) = create_test_repo(); 1585 + 1586 + let file_path = repo_path.join("rebase.txt"); 1587 + fs::write(&file_path, "base\n").expect("Failed to write base file"); 1588 + snapshot_working_copy(&repo_path); 1589 + 1590 + let base_change_id = get_wc_change_id(&repo_path); 1591 + 1592 + let new_source = Command::new("jj") 1593 + .args(["new"]) 1594 + .current_dir(&repo_path) 1595 + .status() 1596 + .expect("Failed to create source revision"); 1597 + assert!(new_source.success(), "jj new failed for source"); 1598 + 1599 + fs::write(&file_path, "base\nsource\n").expect("Failed to write source file"); 1600 + snapshot_working_copy(&repo_path); 1601 + let source_change_id = get_wc_change_id(&repo_path); 1602 + 1603 + let edit_base = Command::new("jj") 1604 + .args(["edit", &base_change_id]) 1605 + .current_dir(&repo_path) 1606 + .status() 1607 + .expect("Failed to edit base revision"); 1608 + assert!(edit_base.success(), "jj edit base failed"); 1609 + 1610 + let new_target = Command::new("jj") 1611 + .args(["new"]) 1612 + .current_dir(&repo_path) 1613 + .status() 1614 + .expect("Failed to create destination revision"); 1615 + assert!(new_target.success(), "jj new failed for destination"); 1616 + 1617 + fs::write(repo_path.join("target.txt"), "target\n").expect("Failed to write target file"); 1618 + snapshot_working_copy(&repo_path); 1619 + let destination_change_id = get_wc_change_id(&repo_path); 1620 + 1621 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1622 + let source_before = jj_repo 1623 + .get_commit(&source_change_id) 1624 + .expect("Failed to load source before rebase"); 1625 + let old_parent_id = source_before 1626 + .parent_ids() 1627 + .first() 1628 + .cloned() 1629 + .expect("Source should have a parent"); 1630 + let destination_commit = jj_repo 1631 + .get_commit(&destination_change_id) 1632 + .expect("Failed to load destination commit"); 1633 + 1634 + let result = jj_repo 1635 + .rebase_revision(&source_change_id, &destination_change_id) 1636 + .expect("rebase_revision should succeed"); 1637 + 1638 + assert!( 1639 + !result.operation_id.is_empty(), 1640 + "rebase should return operation_id" 1641 + ); 1642 + 1643 + let rebased_source = jj_repo 1644 + .get_commit(&source_change_id) 1645 + .expect("Failed to load rebased source commit"); 1646 + let new_parent_id = rebased_source 1647 + .parent_ids() 1648 + .first() 1649 + .expect("Rebased source should have a parent"); 1650 + 1651 + assert_eq!( 1652 + new_parent_id, 1653 + destination_commit.id(), 1654 + "source commit should now have destination as parent" 1655 + ); 1656 + assert_ne!( 1657 + new_parent_id, &old_parent_id, 1658 + "source commit should have a different parent after rebase" 1659 + ); 1660 + 1661 + drop(temp_dir); 1662 + } 1663 + 1664 + #[test] 1665 + fn test_rebase_immutable_rejected() { 1666 + let (temp_dir, repo_path) = create_test_repo(); 1667 + 1668 + let root_change_id = get_root_change_id(&repo_path); 1669 + let destination_change_id = get_wc_change_id(&repo_path); 1670 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1671 + 1672 + let error = jj_repo 1673 + .rebase_revision(&root_change_id, &destination_change_id) 1674 + .expect_err("Rebasing immutable commit should fail"); 1675 + 1676 + assert!( 1677 + error.to_string().contains("immutable"), 1678 + "Error should mention immutable" 1679 + ); 1680 + 1681 + drop(temp_dir); 1682 + } 1683 + 1684 + #[test] 1685 + fn test_rebase_invalid_target_rejected() { 1686 + let (temp_dir, repo_path) = create_test_repo(); 1687 + 1688 + let source_change_id = get_wc_change_id(&repo_path); 1689 + let mut jj_repo = JjRepo::open(&repo_path).expect("Failed to open repo"); 1690 + 1691 + let error = jj_repo 1692 + .rebase_revision(&source_change_id, &source_change_id) 1693 + .expect_err("Rebasing onto itself should fail"); 1694 + 1695 + assert!( 1696 + error.to_string().contains("itself"), 1697 + "Error should mention invalid target" 1698 + ); 1476 1699 1477 1700 drop(temp_dir); 1478 1701 }
+149
apps/desktop/src-tauri/src/repo/jj.rs
··· 9 9 use jj_lib::op_walk; 10 10 use jj_lib::repo::{Repo, StoreFactories}; 11 11 use jj_lib::repo_path::RepoPath; 12 + use jj_lib::rewrite::{self, CommitWithSelection}; 12 13 use jj_lib::settings::UserSettings; 13 14 use jj_lib::workspace::{Workspace, default_working_copy_factories}; 14 15 use serde::Serialize; ··· 487 488 self.workspace 488 489 .check_out(operation_id.clone(), old_tree_id.as_ref(), &new_commit) 489 490 .context("Failed to keep working copy in sync after describe")?; 491 + } 492 + 493 + Ok(MutationResult { 494 + operation_id: operation_id.hex(), 495 + change_id: None, 496 + }) 497 + } 498 + 499 + pub fn squash_revision(&mut self, change_id: &str) -> Result<MutationResult> { 500 + let repo = self.workspace.repo_loader().load_at_head()?; 501 + let mut tx = repo.start_transaction(); 502 + 503 + let source_commit_id = self.resolve_change_id(repo.as_ref(), change_id)?; 504 + let source_commit = repo 505 + .store() 506 + .get_commit(&source_commit_id) 507 + .map_err(|e| anyhow::anyhow!("Failed to get source commit: {}", e))?; 508 + 509 + if source_commit.parent_ids().is_empty() { 510 + anyhow::bail!("Cannot squash root revision: no parent"); 511 + } 512 + if source_commit.parent_ids().len() > 1 { 513 + anyhow::bail!("Cannot squash merge revision with multiple parents"); 514 + } 515 + if source_commit.id() == repo.store().root_commit_id() { 516 + anyhow::bail!("Cannot squash immutable revision"); 517 + } 518 + 519 + let parent_id = source_commit 520 + .parent_ids() 521 + .first() 522 + .cloned() 523 + .context("Cannot squash revision: no parent")?; 524 + let parent_commit = repo 525 + .store() 526 + .get_commit(&parent_id) 527 + .map_err(|e| anyhow::anyhow!("Failed to get parent commit: {}", e))?; 528 + 529 + let workspace_name = self.workspace.workspace_name().to_owned(); 530 + let old_wc_commit_id = repo.view().get_wc_commit_id(&workspace_name).cloned(); 531 + let old_tree_id = old_wc_commit_id 532 + .as_ref() 533 + .and_then(|id| repo.store().get_commit(id).ok()) 534 + .map(|commit| commit.tree_id().clone()); 535 + 536 + let source = CommitWithSelection { 537 + commit: source_commit.clone(), 538 + selected_tree: source_commit.tree()?, 539 + parent_tree: source_commit.parent_tree(repo.as_ref())?, 540 + }; 541 + 542 + let squashed = rewrite::squash_commits(tx.repo_mut(), &[source], &parent_commit, false)?; 543 + let Some(squashed) = squashed else { 544 + anyhow::bail!("Nothing to squash"); 545 + }; 546 + 547 + squashed 548 + .commit_builder 549 + .write() 550 + .map_err(|e| anyhow::anyhow!("Failed to write squashed commit: {}", e))?; 551 + 552 + tx.repo_mut().rebase_descendants()?; 553 + 554 + let new_repo = tx.commit("squash")?; 555 + let operation_id = new_repo.operation().id().clone(); 556 + 557 + if let Some(new_wc_commit_id) = new_repo.view().get_wc_commit_id(&workspace_name) { 558 + if old_wc_commit_id.as_ref() != Some(new_wc_commit_id) { 559 + let new_wc_commit = new_repo 560 + .store() 561 + .get_commit(new_wc_commit_id) 562 + .context("Failed to get new working copy commit")?; 563 + self.workspace 564 + .check_out(operation_id.clone(), old_tree_id.as_ref(), &new_wc_commit) 565 + .context("Failed to update working copy after squash")?; 566 + } 567 + } 568 + 569 + Ok(MutationResult { 570 + operation_id: operation_id.hex(), 571 + change_id: None, 572 + }) 573 + } 574 + 575 + pub fn rebase_revision( 576 + &mut self, 577 + source_change_id: &str, 578 + destination_change_id: &str, 579 + ) -> Result<MutationResult> { 580 + let repo = self.workspace.repo_loader().load_at_head()?; 581 + let mut tx = repo.start_transaction(); 582 + 583 + let source_commit_id = self.resolve_change_id(repo.as_ref(), source_change_id)?; 584 + let source_commit = repo 585 + .store() 586 + .get_commit(&source_commit_id) 587 + .map_err(|e| anyhow::anyhow!("Failed to get source commit: {}", e))?; 588 + 589 + if source_commit.id() == repo.store().root_commit_id() { 590 + anyhow::bail!("Cannot rebase immutable revision"); 591 + } 592 + 593 + let destination_commit_id = self.resolve_change_id(repo.as_ref(), destination_change_id)?; 594 + let destination_commit = repo 595 + .store() 596 + .get_commit(&destination_commit_id) 597 + .map_err(|e| anyhow::anyhow!("Failed to get destination commit: {}", e))?; 598 + 599 + if source_commit.id() == destination_commit.id() { 600 + anyhow::bail!("Cannot rebase revision onto itself"); 601 + } 602 + 603 + if repo 604 + .index() 605 + .is_ancestor(source_commit.id(), destination_commit.id())? 606 + { 607 + anyhow::bail!("Cannot rebase revision onto its descendant"); 608 + } 609 + 610 + let workspace_name = self.workspace.workspace_name().to_owned(); 611 + let old_wc_commit_id = repo.view().get_wc_commit_id(&workspace_name).cloned(); 612 + let old_tree_id = old_wc_commit_id 613 + .as_ref() 614 + .and_then(|id| repo.store().get_commit(id).ok()) 615 + .map(|commit| commit.tree_id().clone()); 616 + 617 + pollster::block_on(rewrite::rebase_commit( 618 + tx.repo_mut(), 619 + source_commit, 620 + vec![destination_commit.id().clone()], 621 + )) 622 + .map_err(|e| anyhow::anyhow!("Failed to rebase revision: {}", e))?; 623 + 624 + tx.repo_mut().rebase_descendants()?; 625 + 626 + let new_repo = tx.commit("rebase")?; 627 + let operation_id = new_repo.operation().id().clone(); 628 + 629 + if let Some(new_wc_commit_id) = new_repo.view().get_wc_commit_id(&workspace_name) { 630 + if old_wc_commit_id.as_ref() != Some(new_wc_commit_id) { 631 + let new_wc_commit = new_repo 632 + .store() 633 + .get_commit(new_wc_commit_id) 634 + .context("Failed to get new working copy commit")?; 635 + self.workspace 636 + .check_out(operation_id.clone(), old_tree_id.as_ref(), &new_wc_commit) 637 + .context("Failed to update working copy after rebase")?; 638 + } 490 639 } 491 640 492 641 Ok(MutationResult {
+101
apps/desktop/src/mocks/setup.ts
··· 1135 1135 change_id: target.change_id, 1136 1136 }; 1137 1137 }, 1138 + jj_squash: (args) => { 1139 + const changeId = args.changeId as string; 1140 + const revisionIndex = mockRevisions.findIndex( 1141 + (r) => r.change_id.startsWith(changeId) || r.change_id_short === changeId, 1142 + ); 1143 + if (revisionIndex < 0) { 1144 + throw new Error(`Change ID not found: ${changeId}`); 1145 + } 1146 + 1147 + const source = mockRevisions[revisionIndex]; 1148 + if (source.is_immutable) { 1149 + throw new Error("Cannot squash immutable revision"); 1150 + } 1151 + if (source.parent_edges.length === 0) { 1152 + throw new Error("Cannot squash root revision: no parent"); 1153 + } 1154 + 1155 + const parentCommitId = source.parent_edges[0].parent_id; 1156 + const parentIndex = mockRevisions.findIndex((r) => r.commit_id === parentCommitId); 1157 + if (parentIndex < 0) { 1158 + throw new Error("Cannot squash revision: parent not found"); 1159 + } 1160 + 1161 + const parent = mockRevisions[parentIndex]; 1162 + const childCommitIds = mockRevisions 1163 + .filter((r) => r.parent_edges.some((edge) => edge.parent_id === source.commit_id)) 1164 + .map((r) => r.commit_id); 1165 + 1166 + mockRevisions = mockRevisions 1167 + .filter((r) => r.commit_id !== source.commit_id) 1168 + .map((revision) => { 1169 + if (!childCommitIds.includes(revision.commit_id)) { 1170 + return revision; 1171 + } 1172 + return { 1173 + ...revision, 1174 + parent_edges: revision.parent_edges.map((edge) => 1175 + edge.parent_id === source.commit_id ? { ...edge, parent_id: parentCommitId } : edge, 1176 + ), 1177 + }; 1178 + }); 1179 + 1180 + if (source.is_working_copy) { 1181 + mockRevisions = mockRevisions.map((revision) => ({ 1182 + ...revision, 1183 + is_working_copy: revision.commit_id === parent.commit_id, 1184 + })); 1185 + } 1186 + 1187 + const allRevisionsRaw = mockRevisions.map( 1188 + ({ change_id_short: _, children_ids: __, ...revision }) => revision, 1189 + ); 1190 + mockRevisions = calculateShortIds(allRevisionsRaw); 1191 + 1192 + return { 1193 + operation_id: nextOperationId(), 1194 + change_id: null, 1195 + }; 1196 + }, 1197 + jj_rebase: (args) => { 1198 + const sourceChangeId = args.sourceChangeId as string; 1199 + const destinationChangeId = args.destinationChangeId as string; 1200 + 1201 + const sourceIndex = mockRevisions.findIndex( 1202 + (r) => r.change_id.startsWith(sourceChangeId) || r.change_id_short === sourceChangeId, 1203 + ); 1204 + if (sourceIndex < 0) { 1205 + throw new Error(`Change ID not found: ${sourceChangeId}`); 1206 + } 1207 + 1208 + const destination = mockRevisions.find( 1209 + (r) => 1210 + r.change_id.startsWith(destinationChangeId) || r.change_id_short === destinationChangeId, 1211 + ); 1212 + if (!destination) { 1213 + throw new Error(`Change ID not found: ${destinationChangeId}`); 1214 + } 1215 + 1216 + const source = mockRevisions[sourceIndex]; 1217 + if (source.is_immutable) { 1218 + throw new Error("Cannot rebase immutable revision"); 1219 + } 1220 + if (source.commit_id === destination.commit_id) { 1221 + throw new Error("Cannot rebase revision onto itself"); 1222 + } 1223 + 1224 + mockRevisions[sourceIndex] = { 1225 + ...source, 1226 + parent_edges: [{ parent_id: destination.commit_id, edge_type: "direct" }], 1227 + }; 1228 + 1229 + const allRevisionsRaw = mockRevisions.map( 1230 + ({ change_id_short: _, children_ids: __, ...revision }) => revision, 1231 + ); 1232 + mockRevisions = calculateShortIds(allRevisionsRaw); 1233 + 1234 + return { 1235 + operation_id: nextOperationId(), 1236 + change_id: null, 1237 + }; 1238 + }, 1138 1239 jj_git_fetch: async (_args) => { 1139 1240 await delay(500); 1140 1241
+15 -2
apps/desktop/src/tauri-commands.ts
··· 157 157 return invoke<MutationResult>("jj_describe", { repoPath, changeId, description }); 158 158 } 159 159 160 - export async function jjGitFetch( 160 + export async function jjSquash(repoPath: string, changeId: string): Promise<MutationResult> { 161 + return invoke<MutationResult>("jj_squash", { repoPath, changeId }); 162 + } 163 + 164 + export async function jjRebase( 161 165 repoPath: string, 162 - remote?: string, 166 + sourceChangeId: string, 167 + destinationChangeId: string, 163 168 ): Promise<MutationResult> { 169 + return invoke<MutationResult>("jj_rebase", { 170 + repoPath, 171 + sourceChangeId, 172 + destinationChangeId, 173 + }); 174 + } 175 + 176 + export async function jjGitFetch(repoPath: string, remote?: string): Promise<MutationResult> { 164 177 return invoke<MutationResult>("jj_git_fetch", { 165 178 repoPath, 166 179 remote: remote ?? null,