native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #192 from supabitapp/fix-worktree-cleanup

Clean up failed worktree creation

authored by

khoi and committed by
GitHub
f96924a3 1f0caf57

+214 -5
+148 -5
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 65 65 title: String, 66 66 message: String, 67 67 pendingID: Worktree.ID, 68 - previousSelection: Worktree.ID? 68 + previousSelection: Worktree.ID?, 69 + repositoryID: Repository.ID, 70 + name: String? 69 71 ) 70 72 case consumeSetupScript(Worktree.ID) 71 73 case consumeTerminalFocus(Worktree.ID) ··· 425 427 state.selectedWorktreeID = pendingID 426 428 let existingNames = Set(repository.worktrees.map { $0.name.lowercased() }) 427 429 return .run { send in 430 + var newWorktreeName: String? 428 431 do { 429 432 let branchNames = try await gitClient.localBranchNames(repository.rootURL) 430 433 let existing = existingNames.union(branchNames) ··· 440 443 title: "No available worktree names", 441 444 message: message, 442 445 pendingID: pendingID, 443 - previousSelection: previousSelection 446 + previousSelection: previousSelection, 447 + repositoryID: repository.id, 448 + name: nil 444 449 ) 445 450 ) 446 451 return 447 452 } 453 + newWorktreeName = name 448 454 let isBareRepository = (try? await gitClient.isBareRepository(repository.rootURL)) ?? false 449 455 let copyIgnored = isBareRepository ? false : copyIgnoredOnWorktreeCreate 450 456 let copyUntracked = isBareRepository ? false : copyUntrackedOnWorktreeCreate ··· 474 480 title: "Unable to create worktree", 475 481 message: error.localizedDescription, 476 482 pendingID: pendingID, 477 - previousSelection: previousSelection 483 + previousSelection: previousSelection, 484 + repositoryID: repository.id, 485 + name: newWorktreeName 478 486 ) 479 487 ) 480 488 } ··· 503 511 let title, 504 512 let message, 505 513 let pendingID, 506 - let previousSelection 514 + let previousSelection, 515 + let repositoryID, 516 + let name 507 517 ): 518 + let previousSelectedWorktree = state.worktree(for: previousSelection) 508 519 removePendingWorktree(pendingID, state: &state) 509 520 restoreSelection(previousSelection, pendingID: pendingID, state: &state) 521 + let cleanup = cleanupFailedWorktree( 522 + repositoryID: repositoryID, 523 + name: name, 524 + state: &state 525 + ) 510 526 state.alert = errorAlert(title: title, message: message) 511 - return .none 527 + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 528 + let selectionChanged = selectionDidChange( 529 + previousSelectionID: previousSelection, 530 + previousSelectedWorktree: previousSelectedWorktree, 531 + selectedWorktreeID: state.selectedWorktreeID, 532 + selectedWorktree: selectedWorktree 533 + ) 534 + var effects: [Effect<Action>] = [] 535 + if cleanup.didRemoveWorktree { 536 + effects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) 537 + } 538 + if selectionChanged { 539 + effects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 540 + } 541 + if cleanup.didUpdatePinned { 542 + let pinnedWorktreeIDs = state.pinnedWorktreeIDs 543 + effects.append( 544 + .run { _ in 545 + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 546 + } 547 + ) 548 + } 549 + if cleanup.didUpdateOrder { 550 + let worktreeOrderByRepository = state.worktreeOrderByRepository 551 + effects.append( 552 + .run { _ in 553 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 554 + } 555 + ) 556 + } 557 + if let cleanupWorktree = cleanup.worktree { 558 + let repositoryRootURL = cleanupWorktree.repositoryRootURL 559 + effects.append( 560 + .run { send in 561 + _ = try? await gitClient.removeWorktree(cleanupWorktree, true) 562 + _ = try? await gitClient.pruneWorktrees(repositoryRootURL) 563 + await send(.reloadRepositories(animated: true)) 564 + } 565 + ) 566 + } 567 + return .merge(effects) 512 568 513 569 case .consumeSetupScript(let id): 514 570 state.pendingSetupScriptWorktreeIDs.remove(id) ··· 1534 1590 } 1535 1591 } 1536 1592 1593 + private struct FailedWorktreeCleanup { 1594 + let didRemoveWorktree: Bool 1595 + let didUpdatePinned: Bool 1596 + let didUpdateOrder: Bool 1597 + let worktree: Worktree? 1598 + } 1599 + 1537 1600 private func removePendingWorktree(_ id: String, state: inout RepositoriesFeature.State) { 1538 1601 state.pendingWorktrees.removeAll { $0.id == id } 1539 1602 } ··· 1576 1639 worktrees: worktrees 1577 1640 ) 1578 1641 return true 1642 + } 1643 + 1644 + private func cleanupFailedWorktree( 1645 + repositoryID: Repository.ID, 1646 + name: String?, 1647 + state: inout RepositoriesFeature.State 1648 + ) -> FailedWorktreeCleanup { 1649 + guard let name, !name.isEmpty else { 1650 + return FailedWorktreeCleanup( 1651 + didRemoveWorktree: false, 1652 + didUpdatePinned: false, 1653 + didUpdateOrder: false, 1654 + worktree: nil 1655 + ) 1656 + } 1657 + let repositoryRootURL = URL(fileURLWithPath: repositoryID).standardizedFileURL 1658 + let baseDirectory = SupacodePaths.repositoryDirectory(for: repositoryRootURL) 1659 + let worktreeURL = baseDirectory.appending(path: name, directoryHint: .isDirectory) 1660 + let worktreeID = worktreeURL.path(percentEncoded: false) 1661 + let worktree = 1662 + state.repositories[id: repositoryID]?.worktrees[id: worktreeID] 1663 + ?? Worktree( 1664 + id: worktreeID, 1665 + name: name, 1666 + detail: "", 1667 + workingDirectory: worktreeURL, 1668 + repositoryRootURL: repositoryRootURL 1669 + ) 1670 + let cleanup = cleanupWorktreeState( 1671 + worktreeID, 1672 + repositoryID: repositoryID, 1673 + state: &state 1674 + ) 1675 + return FailedWorktreeCleanup( 1676 + didRemoveWorktree: cleanup.didRemoveWorktree, 1677 + didUpdatePinned: cleanup.didUpdatePinned, 1678 + didUpdateOrder: cleanup.didUpdateOrder, 1679 + worktree: worktree 1680 + ) 1681 + } 1682 + 1683 + private struct WorktreeCleanupStateResult { 1684 + let didRemoveWorktree: Bool 1685 + let didUpdatePinned: Bool 1686 + let didUpdateOrder: Bool 1687 + } 1688 + 1689 + private func cleanupWorktreeState( 1690 + _ worktreeID: Worktree.ID, 1691 + repositoryID: Repository.ID, 1692 + state: inout RepositoriesFeature.State 1693 + ) -> WorktreeCleanupStateResult { 1694 + let didRemoveWorktree = removeWorktree(worktreeID, repositoryID: repositoryID, state: &state) 1695 + state.pendingWorktrees.removeAll { $0.id == worktreeID } 1696 + state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 1697 + state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 1698 + state.deletingWorktreeIDs.remove(worktreeID) 1699 + state.worktreeInfoByID.removeValue(forKey: worktreeID) 1700 + let didUpdatePinned = state.pinnedWorktreeIDs.contains(worktreeID) 1701 + if didUpdatePinned { 1702 + state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1703 + } 1704 + var didUpdateOrder = false 1705 + if var order = state.worktreeOrderByRepository[repositoryID] { 1706 + let countBefore = order.count 1707 + order.removeAll { $0 == worktreeID } 1708 + if order.count != countBefore { 1709 + didUpdateOrder = true 1710 + if order.isEmpty { 1711 + state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 1712 + } else { 1713 + state.worktreeOrderByRepository[repositoryID] = order 1714 + } 1715 + } 1716 + } 1717 + return WorktreeCleanupStateResult( 1718 + didRemoveWorktree: didRemoveWorktree, 1719 + didUpdatePinned: didUpdatePinned, 1720 + didUpdateOrder: didUpdateOrder 1721 + ) 1579 1722 } 1580 1723 1581 1724 private func updateWorktreeName(
+66
supacodeTests/RepositoriesFeatureTests.swift
··· 457 457 } 458 458 } 459 459 460 + @Test func createRandomWorktreeFailedCleansUpInsertedWorktree() async { 461 + let repoRoot = "/tmp/repo" 462 + let repositoryRootURL = URL(fileURLWithPath: repoRoot) 463 + let baseDirectory = SupacodePaths.repositoryDirectory(for: repositoryRootURL) 464 + let failedName = "swift-goat-938" 465 + let failedWorktreeURL = baseDirectory.appending(path: failedName, directoryHint: .isDirectory) 466 + let failedWorktreeID = failedWorktreeURL.path(percentEncoded: false) 467 + let existingWorktree = makeWorktree(id: "/tmp/repo/wt-main", name: "main", repoRoot: repoRoot) 468 + let failedWorktree = makeWorktree(id: failedWorktreeID, name: failedName, repoRoot: repoRoot) 469 + let repository = makeRepository(id: repoRoot, worktrees: [existingWorktree, failedWorktree]) 470 + let updatedRepository = makeRepository(id: repoRoot, worktrees: [existingWorktree]) 471 + let pendingID = "pending:\(UUID().uuidString)" 472 + var initialState = makeState(repositories: [repository]) 473 + initialState.pendingWorktrees = [ 474 + PendingWorktree( 475 + id: pendingID, 476 + repositoryID: repository.id, 477 + name: "Creating worktree...", 478 + detail: "" 479 + ), 480 + ] 481 + initialState.pendingSetupScriptWorktreeIDs = [failedWorktree.id] 482 + initialState.pendingTerminalFocusWorktreeIDs = [failedWorktree.id] 483 + initialState.deletingWorktreeIDs = [failedWorktree.id] 484 + initialState.pinnedWorktreeIDs = [failedWorktree.id] 485 + initialState.worktreeOrderByRepository = [repository.id: [failedWorktree.id]] 486 + initialState.worktreeInfoByID = [ 487 + failedWorktree.id: WorktreeInfoEntry(addedLines: 1, removedLines: nil, pullRequest: nil) 488 + ] 489 + initialState.selectedWorktreeID = pendingID 490 + let store = TestStore(initialState: initialState) { 491 + RepositoriesFeature() 492 + } withDependencies: { 493 + $0.gitClient.removeWorktree = { worktree, _ in worktree.workingDirectory } 494 + $0.gitClient.pruneWorktrees = { _ in } 495 + $0.gitClient.worktrees = { _ in [existingWorktree] } 496 + } 497 + 498 + await store.send( 499 + .createRandomWorktreeFailed( 500 + title: "Unable to create worktree", 501 + message: "boom", 502 + pendingID: pendingID, 503 + previousSelection: existingWorktree.id, 504 + repositoryID: repository.id, 505 + name: failedName 506 + ) 507 + ) { 508 + $0.pendingWorktrees = [] 509 + $0.pendingSetupScriptWorktreeIDs = [] 510 + $0.pendingTerminalFocusWorktreeIDs = [] 511 + $0.deletingWorktreeIDs = [] 512 + $0.pinnedWorktreeIDs = [] 513 + $0.worktreeOrderByRepository = [:] 514 + $0.worktreeInfoByID = [:] 515 + $0.selectedWorktreeID = existingWorktree.id 516 + $0.repositories = [updatedRepository] 517 + } 518 + 519 + await store.receive(\.delegate.repositoriesChanged) 520 + await store.receive(\.reloadRepositories) 521 + await store.receive(\.repositoriesLoaded) { 522 + $0.isInitialLoadComplete = true 523 + } 524 + } 525 + 460 526 private func makeWorktree(id: String, name: String, repoRoot: String = "/tmp/repo") -> Worktree { 461 527 Worktree( 462 528 id: id,