diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 51a0acda05..77bd4b1a8e 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -15,13 +15,19 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout import com.itsaky.androidide.R import com.itsaky.androidide.activities.PreferencesActivity import com.itsaky.androidide.activities.editor.EditorHandlerActivity import com.itsaky.androidide.databinding.FragmentGitBottomSheetBinding +import com.itsaky.androidide.events.ListProjectFilesRequestEvent import com.itsaky.androidide.fragments.git.adapter.GitFileChangeAdapter import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.models.ChangeType +import com.itsaky.androidide.git.core.models.FileChange +import com.itsaky.androidide.git.core.models.GitBranch +import com.itsaky.androidide.git.core.models.GitStatus import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.IEditorHandler @@ -30,428 +36,691 @@ import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.onLongPress import com.itsaky.androidide.viewmodel.BottomSheetViewModel import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel +import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.BranchesUiState import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.PullUiState import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus import org.koin.androidx.viewmodel.ext.android.activityViewModel import java.io.File class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { - - private val viewModel: GitBottomSheetViewModel by activityViewModel() - private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() - private lateinit var fileChangeAdapter: GitFileChangeAdapter - private lateinit var credentialsManager: GitCredentialsManager - - private var _binding: FragmentGitBottomSheetBinding? = null - private val binding get() = _binding!! - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - _binding = FragmentGitBottomSheetBinding.bind(view) - credentialsManager = GitCredentialsManager(requireContext()) - - fileChangeAdapter = GitFileChangeAdapter( - onFileClicked = { change -> - when (change.type) { - ChangeType.CONFLICTED -> { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - viewLifecycleOwner.lifecycleScope.launch { - val repo = viewModel.currentRepository - repo?.let { - activity.checkForExternalFileChanges(force = true) - activity.openFile(File(repo.rootDir, change.path)) - bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) - } - } - } - } - - else -> { - val dialog = GitDiffViewerDialog.newInstance(change.path) - dialog.show(childFragmentManager, "GitDiffViewerDialog") - } - } - }, - onSelectionChanged = { - validateCommitButton() - updateCheckAllButton() - }, - onResolveConflict = { change -> - viewModel.resolveConflict(change.path) - } - ) - - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerView.adapter = fileChangeAdapter - binding.recyclerView.onLongPress { _ -> - TooltipManager.showIdeCategoryTooltip( - context = requireContext(), - anchorView = binding.recyclerView, - tag = TooltipTag.PROJECT_GIT_FILES, - ) - } - - viewLifecycleOwner.lifecycleScope.launch { - launch { - viewModel.currentBranch.collectLatest { branchName -> - if (branchName != null) { - binding.tvBranchName.visibility = View.VISIBLE - binding.tvBranchName.text = - getString(R.string.current_branch_name, branchName) - } else { - binding.tvBranchName.visibility = View.GONE - } - } - } - - combine( - viewModel.isGitRepository, - viewModel.gitStatus - ) { isRepo, status -> - val allChanges = - status.staged + status.unstaged + status.untracked + status.conflicted - - when { - !isRepo -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.not_a_git_repo) - recyclerView.visibility = View.GONE - btnCheckAll.visibility = View.GONE - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.GONE - btnAbortMerge.visibility = View.GONE - } - - allChanges.isEmpty() -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.no_uncommitted_changes) - recyclerView.visibility = View.GONE - btnCheckAll.visibility = View.GONE - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.VISIBLE - btnAbortMerge.visibility = View.GONE - } - - else -> { - // Only offer "Check All" when there is at least one - // non-conflicted file; conflicted files can't be staged. - val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } - binding.apply { - emptyView.visibility = View.GONE - recyclerView.visibility = View.VISIBLE - btnCheckAll.visibility = - if (hasSelectable) View.VISIBLE else View.GONE - commitSection.visibility = View.VISIBLE - authorWarning.visibility = - if (hasAuthorInfo()) View.GONE else View.VISIBLE - commitHistoryButton.visibility = View.VISIBLE - btnAbortMerge.visibility = - if (status.isMerging) View.VISIBLE else View.GONE - } - fileChangeAdapter.submitList(allChanges) { - updateCheckAllButton() - } - } - } - }.collectLatest { } - } - - setupCommitUI() - - binding.commitHistoryButton.apply { - setOnClickListener { - val dialog = GitCommitHistoryDialog() - dialog.show(childFragmentManager, "CommitHistoryDialog") - } - setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT_HISTORY) - } - - setupPullUI() - } - - override fun onResume() { - super.onResume() - updateAuthorUI() - } - - private fun updateAuthorUI() { - val hasAuthor = hasAuthorInfo() - val allChanges = - viewModel.gitStatus.value.staged + viewModel.gitStatus.value.unstaged + viewModel.gitStatus.value.untracked + viewModel.gitStatus.value.conflicted - binding.authorWarning.visibility = - if (!hasAuthor && allChanges.isNotEmpty()) View.VISIBLE else View.GONE - validateCommitButton() - } - - private fun hasAuthorInfo(): Boolean { - return !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() - } - - private fun setupCommitUI() { - binding.commitSummary.doAfterTextChanged { validateCommitButton() } - binding.commitDescription.doAfterTextChanged { validateCommitButton() } - - binding.btnCheckAll.setOnClickListener { - if (fileChangeAdapter.areAllSelected()) { - fileChangeAdapter.clearSelection() - } else { - fileChangeAdapter.selectAll() - } - } - - binding.btnAbortMerge.apply { - setOnClickListener { - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.abort_merge) - .setMessage(R.string.confirm_abort_merge) - .setPositiveButton(R.string.abort_merge) { _, _ -> - viewModel.abortMerge { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - activity.checkForExternalFileChanges(force = true) - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_ABORT_MERGE) - dialog.show() - } - setTooltipOnView(TooltipTag.PROJECT_GIT_ABORT) - } - - binding.authorAvatar.apply { - setOnClickListener { showAuthorPopup() } - setTooltipOnView(TooltipTag.PROJECT_GIT_ID) - } - - binding.commitButton.apply { - setOnClickListener { - checkUnsavedChangesAndProceed { - val summary = binding.commitSummary.text?.toString()?.trim() ?: "" - val description = binding.commitDescription.text?.toString()?.trim() - - if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { - viewModel.commitChanges( - summary = summary, - description = description, - selectedPaths = fileChangeAdapter.selectedFiles.toList() - ) { - // Clear the inputs on successful commit - binding.commitSummary.text?.clear() - binding.commitDescription.text?.clear() - fileChangeAdapter.selectedFiles.clear() - updateCheckAllButton() - } - } - } - } - setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) - } - } - - private fun showAuthorPopup() { - val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) } - val email = - GitPreferences.userEmail.orEmpty().ifBlank { getString(R.string.author_not_set) } - val message = getString(R.string.git_committing_as, name) + "\n" + - getString(R.string.git_committing_email, email) + "\n\n" + - getString(R.string.git_update_config_in_preferences) - - val spannable = SpannableString(message) - val preferencesText = getString(R.string.git_update_config_in_preferences) - val startIndex = message.indexOf(preferencesText) - - val builder = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.idepref_git_author_title) - .setMessage(spannable) - .setPositiveButton(android.R.string.ok, null) - - val dialog = builder.create() - - if (startIndex != -1) { - spannable.setSpan( - object : ClickableSpan() { - override fun onClick(widget: View) { - val intent = Intent( - requireContext(), - PreferencesActivity::class.java - ) - dialog.dismiss() - startActivity(intent) - } - }, - startIndex, - startIndex + preferencesText.length, - SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - - dialog.show() - dialog.findViewById(android.R.id.message)?.movementMethod = - LinkMovementMethod.getInstance() - } - - private fun validateCommitButton() { - // May be invoked from async adapter callbacks; bail if the view is gone. - val binding = _binding ?: return - val hasSummary = !binding.commitSummary.text.isNullOrBlank() - val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty() - val hasAuthor = hasAuthorInfo() - binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor - } - - private fun updateCheckAllButton() { - // May be invoked from the async submitList commit callback; bail if the view is gone. - val binding = _binding ?: return - binding.btnCheckAll.setText( - if (fileChangeAdapter.areAllSelected()) R.string.uncheck_all else R.string.check_all - ) - } - - private fun setupPullUI() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.isGitRepository.collectLatest { isRepo -> - binding.btnPull.visibility = if (isRepo) View.VISIBLE else View.GONE - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.pullState.collectLatest { state -> - when (state) { - is PullUiState.Idle -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - } - - is PullUiState.Pulling -> { - binding.btnPull.isEnabled = false - binding.pullProgress.visibility = View.VISIBLE - } - - is PullUiState.Success -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - flashSuccess(R.string.pull_successful) - viewModel.resetPullState() - refreshEditorContent() - } - - is PullUiState.Conflicts -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - val message = state.message ?: getString(R.string.info_merge_conflicts) - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.merge_conflicts)) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_MERGE_CONFLICTS) - dialog.show() - viewModel.resetPullState() - refreshEditorContent() - } - - is PullUiState.Error -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - val message = - state.message ?: state.errorResId?.let { resId -> - if (state.errorArgs != null) getString( - resId, - *state.errorArgs.toTypedArray() - ) else getString(resId) - } - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.pull_failed) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_PULL_FAIL) - dialog.show() - } - } - } - } - - binding.btnPull.apply { - setOnClickListener { - checkUnsavedChangesAndProceed { - val username = credentialsManager.getUsername() - val token = credentialsManager.getToken() - if (!username.isNullOrBlank() && !token.isNullOrBlank()) { - viewModel.pull(username, token) - } else { - showGitCredentialsDialog( - credentialsManager = credentialsManager, - positiveButtonTextResId = R.string.pull - ) { user, accessToken -> - viewModel.pull(user, accessToken) - } - } - } - } - setTooltipOnView(TooltipTag.GIT_PULL) - } - } - - private fun refreshEditorContent(force: Boolean = false) { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - activity.checkForExternalFileChanges(force) - } - } - - private fun checkUnsavedChangesAndProceed(action: () -> Unit) { - val handler = requireActivity() as? IEditorHandler - if (handler?.areFilesModified() == true) { - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.title_files_unsaved) - .setMessage(R.string.msg_save_before_git_action) - .setPositiveButton(R.string.save_before_git_action) { _, _ -> - handler.saveAllAsync { action() } - } - .setNegativeButton(R.string.no_save_before_git_action) { _, _ -> - action() - } - .setNeutralButton(android.R.string.cancel, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_SAVE) - dialog.show() - } else { - action() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun AlertDialog.setTooltipOnDialog(tag: String) { - onLongPress { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = tag - ) - true - } - } - - private fun View.setTooltipOnView(tag: String) { - setOnLongClickListener { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = tag - ) - true - } - } + private val viewModel: GitBottomSheetViewModel by activityViewModel() + private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() + private lateinit var fileChangeAdapter: GitFileChangeAdapter + private lateinit var credentialsManager: GitCredentialsManager + private lateinit var branchPopupWindow: GitBranchPopupWindow + + private var _binding: FragmentGitBottomSheetBinding? = null + val binding: FragmentGitBottomSheetBinding + get() = checkNotNull(_binding) { "Fragment binding is null or view has been destroyed" } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentGitBottomSheetBinding.bind(view) + credentialsManager = GitCredentialsManager(requireContext()) + viewModel.initializeRepository() + + branchPopupWindow = + GitBranchPopupWindow( + context = requireContext(), + onBranchSelected = { branch -> + showCheckoutDialog(branch) + }, + onNewBranchRequested = { + showCreateBranchDialog() + }, + onMergeBranch = { branch -> + showMergeDialog(viewModel.currentBranch.value ?: "HEAD", branch.name) + }, + ) + + binding.tvBranchName.setOnClickListener { + branchPopupWindow.show(binding.tvBranchName) + } + + fileChangeAdapter = + GitFileChangeAdapter( + onFileClicked = { change -> + when (change.type) { + ChangeType.CONFLICTED -> { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + viewLifecycleOwner.lifecycleScope.launch { + val repo = viewModel.currentRepository + repo?.let { + activity.checkForExternalFileChanges(force = true) + activity.openFile(File(repo.rootDir, change.path)) + bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) + } + } + } + } + + else -> { + val dialog = GitDiffViewerDialog.newInstance(change.path) + dialog.show(childFragmentManager, "GitDiffViewerDialog") + } + } + }, + onSelectionChanged = { + validateCommitButton() + updateCheckAllButton() + }, + onResolveConflict = { change -> + viewModel.resolveConflict(change.path) + }, + ) + + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = fileChangeAdapter + binding.recyclerView.onLongPress { _ -> + TooltipManager.showIdeCategoryTooltip( + context = requireContext(), + anchorView = binding.recyclerView, + tag = TooltipTag.PROJECT_GIT_FILES, + ) + } + + viewLifecycleOwner.lifecycleScope.launch { + launch { + viewModel.currentBranch.collectLatest { branchName -> + if (branchName != null) { + binding.groupCurrentBranch.visibility = View.VISIBLE + binding.tvBranchName.text = branchName + binding.tvBranchName.contentDescription = + getString(R.string.current_branch_name, branchName) + } else { + binding.groupCurrentBranch.visibility = View.GONE + } + } + } + + launch { + viewModel.branches.collectLatest { state -> + binding.tvBranchName.isEnabled = state !is BranchesUiState.Loading + branchPopupWindow.setBranchesState(state) + } + } + + launch { + viewModel.checkoutState.collectLatest { state -> + when (state) { + is GitBottomSheetViewModel.CheckoutUiState.Idle -> { + binding.tvBranchName.isEnabled = true + } + + is GitBottomSheetViewModel.CheckoutUiState.CheckingOut -> { + binding.tvBranchName.isEnabled = false + } + + is GitBottomSheetViewModel.CheckoutUiState.Success -> { + binding.tvBranchName.isEnabled = true + flashSuccess(getString(R.string.git_checkout_success, state.branchName)) + refreshEditorContent(force = true) + EventBus.getDefault().post(ListProjectFilesRequestEvent()) + viewModel.resetCheckoutState() + } + + is GitBottomSheetViewModel.CheckoutUiState.Conflicts -> { + binding.tvBranchName.isEnabled = true + val message = + getString( + R.string.git_checkout_conflict_msg, + state.conflictingPaths.joinToString("\n• ", prefix = "• "), + ) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_checkout_conflict_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.resetCheckoutState() + }.setOnDismissListener { + viewModel.resetCheckoutState() + }.show() + } + + is GitBottomSheetViewModel.CheckoutUiState.Error -> { + binding.tvBranchName.isEnabled = true + val message = formatError(state.errorResId, state.errorArgs) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_checkout_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.resetCheckoutState() + }.setOnDismissListener { + viewModel.resetCheckoutState() + }.show() + } + } + } + } + + launch { + viewModel.mergeState.collectLatest { state -> + when (state) { + is GitBottomSheetViewModel.MergeUiState.Idle -> { + binding.tvBranchName.isEnabled = true + } + + is GitBottomSheetViewModel.MergeUiState.Merging -> { + binding.tvBranchName.isEnabled = false + } + + is GitBottomSheetViewModel.MergeUiState.Success -> { + binding.tvBranchName.isEnabled = true + flashSuccess( + getString( + R.string.git_merge_success, + state.targetBranch, + state.currentBranch, + ), + ) + refreshEditorContent(force = true) + EventBus.getDefault().post(ListProjectFilesRequestEvent()) + viewModel.resetMergeState() + } + + is GitBottomSheetViewModel.MergeUiState.AlreadyUpToDate -> { + binding.tvBranchName.isEnabled = true + flashSuccess(getString(R.string.git_already_up_to_date)) + viewModel.resetMergeState() + } + + is GitBottomSheetViewModel.MergeUiState.Conflicts -> { + binding.tvBranchName.isEnabled = true + val message = + getString( + R.string.git_merge_conflict_msg, + state.targetBranch, + state.currentBranch, + ) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_merge_conflict_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.resetMergeState() + }.setOnDismissListener { + viewModel.resetMergeState() + }.show() + refreshEditorContent(force = true) + EventBus.getDefault().post(ListProjectFilesRequestEvent()) + } + + is GitBottomSheetViewModel.MergeUiState.Error -> { + binding.tvBranchName.isEnabled = true + val message = formatError(state.errorResId, state.errorArgs) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_merge_failed_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.resetMergeState() + }.setOnDismissListener { + viewModel.resetMergeState() + }.show() + } + } + } + } + + launch { + viewModel.isGitRepository.collectLatest { isRepo -> + if (isRepo) { + viewModel.fetchBranches() + } + } + } + + combine( + viewModel.isGitRepository, + viewModel.gitStatus, + ) { isRepo, status -> + val allChanges = status.allChanges() + + when { + !isRepo -> { + binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.not_a_git_repo) + recyclerView.visibility = View.GONE + cbCheckAll.visibility = View.GONE + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.GONE + btnAbortMerge.visibility = View.GONE + } + } + + allChanges.isEmpty() -> { + binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.no_uncommitted_changes) + recyclerView.visibility = View.GONE + cbCheckAll.visibility = View.VISIBLE + cbCheckAll.isEnabled = false + cbCheckAll.isChecked = false + cbCheckAll.text = getString(R.string.changed_files_count, 0) + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = View.GONE + } + } + + else -> { + val hasSelectable = allChanges.hasSelectable() + binding.apply { + emptyView.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + cbCheckAll.visibility = View.VISIBLE + cbCheckAll.isEnabled = hasSelectable + cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) + commitSection.visibility = View.VISIBLE + authorWarning.visibility = + if (hasAuthorInfo()) View.GONE else View.VISIBLE + commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = + if (status.isMerging) View.VISIBLE else View.GONE + } + fileChangeAdapter.submitList(allChanges) { + updateCheckAllButton() + } + } + } + }.collectLatest { } + } + + setupCommitUI() + + binding.commitHistoryButton.apply { + setOnClickListener { + val dialog = GitCommitHistoryDialog() + dialog.show(childFragmentManager, "CommitHistoryDialog") + } + setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT_HISTORY) + } + + setupPullUI() + } + + override fun onResume() { + super.onResume() + updateAuthorUI() + } + + private fun updateAuthorUI() { + val hasAuthor = hasAuthorInfo() + val allChanges = viewModel.gitStatus.value.allChanges() + binding.authorWarning.visibility = + if (!hasAuthor && allChanges.isNotEmpty()) View.VISIBLE else View.GONE + validateCommitButton() + } + + private fun hasAuthorInfo(): Boolean = !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() + + private fun setupCommitUI() { + binding.commitSummary.doAfterTextChanged { validateCommitButton() } + binding.commitDescription.doAfterTextChanged { validateCommitButton() } + + binding.cbCheckAll.setOnClickListener { + if (binding.cbCheckAll.isChecked) { + fileChangeAdapter.selectAll() + } else { + fileChangeAdapter.clearSelection() + } + validateCommitButton() + } + + binding.btnAbortMerge.apply { + setOnClickListener { + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.abort_merge) + .setMessage(R.string.confirm_abort_merge) + .setPositiveButton(R.string.abort_merge) { _, _ -> + viewModel.abortMerge { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force = true) + } + } + }.setNegativeButton(android.R.string.cancel, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_ABORT_MERGE) + dialog.show() + } + setTooltipOnView(TooltipTag.PROJECT_GIT_ABORT) + } + + binding.authorAvatar.apply { + setOnClickListener { showAuthorPopup() } + setTooltipOnView(TooltipTag.PROJECT_GIT_ID) + } + + binding.commitButton.apply { + setOnClickListener { + checkUnsavedChangesAndProceed { + val summary = + binding.commitSummary.text + ?.toString() + ?.trim() ?: "" + val description = + binding.commitDescription.text + ?.toString() + ?.trim() + + if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { + viewModel.commitChanges( + summary = summary, + description = description, + selectedPaths = fileChangeAdapter.selectedFiles.toList(), + ) { + // Clear the inputs on successful commit + binding.commitSummary.text?.clear() + binding.commitDescription.text?.clear() + fileChangeAdapter.selectedFiles.clear() + updateCheckAllButton() + } + } + } + } + setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) + } + } + + private fun showAuthorPopup() { + val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) } + val email = + GitPreferences.userEmail.orEmpty().ifBlank { getString(R.string.author_not_set) } + val message = + getString(R.string.git_committing_as, name) + "\n" + + getString(R.string.git_committing_email, email) + "\n\n" + + getString(R.string.git_update_config_in_preferences) + + val spannable = SpannableString(message) + val preferencesText = getString(R.string.git_update_config_in_preferences) + val startIndex = message.indexOf(preferencesText) + + val builder = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.idepref_git_author_title) + .setMessage(spannable) + .setPositiveButton(android.R.string.ok, null) + + val dialog = builder.create() + + if (startIndex != -1) { + spannable.setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) { + val intent = + Intent( + requireContext(), + PreferencesActivity::class.java, + ) + dialog.dismiss() + startActivity(intent) + } + }, + startIndex, + startIndex + preferencesText.length, + SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + + dialog.show() + dialog.findViewById(android.R.id.message)?.movementMethod = + LinkMovementMethod.getInstance() + } + + private fun validateCommitButton() { + // May be invoked from async adapter callbacks; bail if the view is gone. + val binding = _binding ?: return + val hasSummary = !binding.commitSummary.text.isNullOrBlank() + val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty() + val hasAuthor = hasAuthorInfo() + binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor + } + + private fun updateCheckAllButton() { + // May be invoked from the async submitList commit callback; bail if the view is gone. + val binding = _binding ?: return + val allChanges = viewModel.gitStatus.value.allChanges() + val hasSelectable = allChanges.hasSelectable() + binding.cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) + binding.cbCheckAll.isEnabled = hasSelectable && allChanges.isNotEmpty() + binding.cbCheckAll.isChecked = hasSelectable && allChanges.isNotEmpty() && fileChangeAdapter.areAllSelected() + } + + private fun setupPullUI() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isGitRepository.collectLatest { isRepo -> + binding.btnPull.visibility = if (isRepo) View.VISIBLE else View.GONE + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.pullState.collectLatest { state -> + when (state) { + is PullUiState.Idle -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + } + + is PullUiState.Pulling -> { + binding.btnPull.isEnabled = false + binding.pullProgress.visibility = View.VISIBLE + } + + is PullUiState.Success -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + flashSuccess(R.string.pull_successful) + viewModel.resetPullState() + refreshEditorContent() + } + + is PullUiState.Conflicts -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = state.message ?: getString(R.string.info_merge_conflicts) + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.merge_conflicts)) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_MERGE_CONFLICTS) + dialog.show() + viewModel.resetPullState() + refreshEditorContent() + } + + is PullUiState.Error -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = + state.message ?: state.errorResId?.let { resId -> + if (state.errorArgs != null) { + getString( + resId, + *state.errorArgs.toTypedArray(), + ) + } else { + getString(resId) + } + } + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.pull_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_PULL_FAIL) + dialog.show() + } + } + } + } + + binding.btnPull.apply { + setOnClickListener { + checkUnsavedChangesAndProceed { + val username = credentialsManager.getUsername() + val token = credentialsManager.getToken() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.pull(username, token) + } else { + showGitCredentialsDialog( + credentialsManager = credentialsManager, + positiveButtonTextResId = R.string.pull, + ) { user, accessToken -> + viewModel.pull(user, accessToken) + } + } + } + } + setTooltipOnView(TooltipTag.GIT_PULL) + } + } + + private fun showCreateBranchDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_git_create_branch, null) + val branchNameLayout = dialogView.findViewById(R.id.branchNameLayout) + val etBranchName = dialogView.findViewById(R.id.etBranchName) + + etBranchName?.doAfterTextChanged { + branchNameLayout?.error = null + } + + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.git_create_branch_title) + .setView(dialogView) + .setPositiveButton(R.string.git_create_branch, null) + .setNegativeButton(android.R.string.cancel, null) + .create() + + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val branchName = etBranchName?.text?.toString()?.trim() ?: "" + if (branchName.isNotBlank()) { + checkUnsavedChangesAndProceed { + dialog.dismiss() + viewModel.checkoutBranch(branchName = branchName, createNew = true) + } + } else { + branchNameLayout?.error = getString(R.string.git_create_branch_invalid_name) + } + } + } + + dialog.show() + } + + private fun showMergeDialog( + currentBranch: String, + targetBranch: String, + ) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.merge_dialog_title)) + .setMessage(getString(R.string.merge_dialog_message, targetBranch, currentBranch)) + .setPositiveButton(R.string.proceed_with_git_action) { _, _ -> + checkUnsavedChangesAndProceed { + viewModel.mergeBranch(targetBranch) + } + }.setNegativeButton(android.R.string.cancel) { _, _ -> } + .setCancelable(true) + .show() + } + + private fun showCheckoutDialog(targetBranch: GitBranch) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.checkout_dialog_title)) + .setMessage(getString(R.string.checkout_dialog_message, targetBranch.name)) + .setPositiveButton(R.string.proceed_with_git_action) { _, _ -> + if (!targetBranch.isCurrent) { + checkUnsavedChangesAndProceed { + viewModel.checkoutBranch( + branchName = targetBranch.name, + createNew = false, + ) + } + } + }.setNegativeButton(android.R.string.cancel) { _, _ -> } + .setCancelable(true) + .show() + } + + private fun formatError( + errorResId: Int, + errorArgs: List?, + ): String = + if (errorArgs.isNullOrEmpty()) { + getString(errorResId) + } else { + getString(errorResId, *errorArgs.toTypedArray()) + } + + private fun refreshEditorContent(force: Boolean = false) { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force) + } + } + + private fun checkUnsavedChangesAndProceed(action: () -> Unit) { + val handler = requireActivity() as? IEditorHandler + if (handler?.areFilesModified() == true) { + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.title_files_unsaved) + .setMessage(R.string.msg_save_before_git_action) + .setPositiveButton(R.string.save_before_git_action) { _, _ -> + handler.saveAllAsync { action() } + }.setNegativeButton(R.string.no_save_before_git_action) { _, _ -> + action() + }.setNeutralButton(android.R.string.cancel, null) + .create() + dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_SAVE) + dialog.show() + } else { + action() + } + } + + override fun onDestroyView() { + branchPopupWindow.dismiss() + super.onDestroyView() + _binding = null + } + + private fun AlertDialog.setTooltipOnDialog(tag: String) { + onLongPress { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = tag, + ) + true + } + } + + private fun View.setTooltipOnView(tag: String) { + setOnLongClickListener { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = tag, + ) + true + } + } + + private fun GitStatus.allChanges(): List = staged + unstaged + untracked + conflicted + + private fun List.hasSelectable(): Boolean = any { it.type != ChangeType.CONFLICTED } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt new file mode 100644 index 0000000000..e92f8ef12c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBranchPopupWindow.kt @@ -0,0 +1,196 @@ +package com.itsaky.androidide.fragments.git + +import android.content.Context +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupWindow +import androidx.core.graphics.drawable.toDrawable +import androidx.core.widget.doAfterTextChanged +import androidx.recyclerview.widget.LinearLayoutManager +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.PopupGitBranchesBinding +import com.itsaky.androidide.fragments.git.adapter.GitBranchAdapter +import com.itsaky.androidide.fragments.git.adapter.GitBranchListItem +import com.itsaky.androidide.git.core.models.GitBranch +import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel +import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.BranchesUiState + +/** + * Popup window that displays the list of local and remote branches with search, + * branch creation, checkout, and merge capabilities. + * + * @param context The host context. + * @param onBranchSelected Callback invoked when a branch row is tapped to switch/checkout. + * @param onNewBranchRequested Callback invoked when the "New branch" action is tapped. + * @param onMergeBranch Optional callback invoked when a branch's merge button is tapped. + */ +class GitBranchPopupWindow( + private val context: Context, + private val onBranchSelected: (GitBranch) -> Unit, + private val onNewBranchRequested: () -> Unit, + private val onMergeBranch: ((GitBranch) -> Unit)? = null, +) { + private val binding: PopupGitBranchesBinding = + PopupGitBranchesBinding.inflate( + LayoutInflater.from(context), + ) + + private val popupWindow: PopupWindow = + PopupWindow( + binding.root, + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + true, + ).apply { + setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + elevation = 16f + } + + private val adapter: GitBranchAdapter = + GitBranchAdapter( + onBranchSelected = { branch -> + popupWindow.dismiss() + onBranchSelected(branch) + }, + onMergeClicked = { branch -> + popupWindow.dismiss() + onMergeBranch?.invoke(branch) + }, + ) + + private var allBranches: List = emptyList() + + init { + binding.rvBranches.layoutManager = LinearLayoutManager(context) + binding.rvBranches.adapter = adapter + + binding.btnNewBranch.setOnClickListener { + popupWindow.dismiss() + onNewBranchRequested() + } + + binding.etSearchBranches.doAfterTextChanged { text -> + filterBranches(text?.toString()) + } + } + + /** + * Updates the branch list and loading indicator state in the popup. + * + * @param state The current [BranchesUiState] to render. + */ + fun setBranchesState(state: BranchesUiState) { + when (state) { + is BranchesUiState.Loading -> { + binding.branchesProgress.visibility = View.VISIBLE + binding.tvEmptyBranches.visibility = View.GONE + binding.rvBranches.visibility = View.VISIBLE + } + + is BranchesUiState.Success -> { + binding.branchesProgress.visibility = View.GONE + allBranches = state.branches + filterBranches(binding.etSearchBranches.text?.toString()) + } + + is BranchesUiState.None -> { + binding.branchesProgress.visibility = View.GONE + binding.tvEmptyBranches.visibility = View.GONE + binding.rvBranches.visibility = View.VISIBLE + allBranches = emptyList() + adapter.submitList(emptyList()) + } + + is BranchesUiState.Error -> { + binding.branchesProgress.visibility = View.GONE + allBranches = emptyList() + binding.tvEmptyBranches.text = context.getString(R.string.git_branches_load_failed) + binding.tvEmptyBranches.visibility = View.VISIBLE + binding.rvBranches.visibility = View.GONE + adapter.submitList(emptyList()) + } + } + } + + private fun filterBranches(query: String?) { + val filtered = + if (query.isNullOrBlank()) { + allBranches + } else { + allBranches.filter { branch -> + val displayName = getDisplayName(branch) + branch.name.contains(query, ignoreCase = true) || displayName.contains(query, ignoreCase = true) + } + } + + val localBranches = filtered.filter { !it.isRemote } + val remoteBranches = filtered.filter { it.isRemote } + + val items = mutableListOf() + + if (localBranches.isNotEmpty()) { + items.add(GitBranchListItem.Header(context.getString(R.string.git_local_branches))) + localBranches.forEach { branch -> + items.add(GitBranchListItem.BranchItem(branch, getDisplayName(branch))) + } + } + + if (remoteBranches.isNotEmpty()) { + items.add(GitBranchListItem.Header(context.getString(R.string.git_remote_branches))) + remoteBranches.forEach { branch -> + items.add(GitBranchListItem.BranchItem(branch, getDisplayName(branch))) + } + } + + if (items.isEmpty()) { + binding.tvEmptyBranches.text = context.getString(R.string.git_no_branches_found) + binding.tvEmptyBranches.visibility = View.VISIBLE + binding.rvBranches.visibility = View.GONE + } else { + binding.tvEmptyBranches.visibility = View.GONE + binding.rvBranches.visibility = View.VISIBLE + } + + adapter.submitList(items) + } + + private fun getDisplayName(branch: GitBranch): String = branch.name + + /** + * Displays the popup window centered horizontally on the screen below [anchor]. + * + * Clears any existing search input, resets the filtered branch list, + * dynamically sizes the popup, and positions the dropdown centered on screen. + * + * @param anchor The view below which the popup dropdown should be displayed. + */ + fun show(anchor: View) { + binding.etSearchBranches.text?.clear() + filterBranches(null) + val displayWidth = context.resources.displayMetrics.widthPixels + val density = context.resources.displayMetrics.density + val maxWidth = (360 * density).toInt() + val margin = (32 * density).toInt() + val targetWidth = maxWidth.coerceAtMost(displayWidth - margin) + popupWindow.width = targetWidth + + val location = IntArray(2) + anchor.getLocationOnScreen(location) + val anchorX = location[0] + val desiredX = (displayWidth - targetWidth) / 2 + val xOffset = desiredX - anchorX + + popupWindow.showAsDropDown(anchor, xOffset, (8 * density).toInt()) + } + + /** + * Dismisses the popup window if it is currently showing. + */ + fun dismiss() { + if (popupWindow.isShowing) { + popupWindow.dismiss() + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt new file mode 100644 index 0000000000..1325adfd40 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitBranchAdapter.kt @@ -0,0 +1,144 @@ +package com.itsaky.androidide.fragments.git.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.ItemGitBranchBinding +import com.itsaky.androidide.git.core.models.GitBranch + +/** + * Represents items rendered in the Git branch selection list. + */ +sealed class GitBranchListItem { + /** + * Section header dividing branch categories (e.g., Local vs Remote). + */ + data class Header( + val title: String, + ) : GitBranchListItem() + + /** + * Selectable branch item row. + */ + data class BranchItem( + val branch: GitBranch, + val displayName: String, + ) : GitBranchListItem() +} + +/** + * RecyclerView adapter for displaying local and remote Git branches with selection + * and optional merge action callbacks. + * + * @param onBranchSelected Callback invoked when a branch row is tapped to switch/checkout. + * @param onMergeClicked Optional callback invoked when the merge button for a branch is tapped. + */ +class GitBranchAdapter( + private val onBranchSelected: (GitBranch) -> Unit, + private val onMergeClicked: ((GitBranch) -> Unit)? = null, +) : ListAdapter(DiffCallback) { + companion object { + private const val VIEW_TYPE_HEADER = 0 + private const val VIEW_TYPE_BRANCH = 1 + } + + override fun getItemViewType(position: Int): Int = + when (getItem(position)) { + is GitBranchListItem.Header -> VIEW_TYPE_HEADER + is GitBranchListItem.BranchItem -> VIEW_TYPE_BRANCH + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == VIEW_TYPE_HEADER) { + val view = inflater.inflate(R.layout.item_git_branch_header, parent, false) + HeaderViewHolder(view) + } else { + val binding = ItemGitBranchBinding.inflate(inflater, parent, false) + BranchViewHolder(binding) + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + when (val item = getItem(position)) { + is GitBranchListItem.Header -> (holder as HeaderViewHolder).bind(item) + is GitBranchListItem.BranchItem -> (holder as BranchViewHolder).bind(item) + } + } + + inner class HeaderViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { + private val tvTitle: TextView = itemView.findViewById(R.id.tvHeaderTitle) + + fun bind(item: GitBranchListItem.Header) { + tvTitle.text = item.title + } + } + + inner class BranchViewHolder( + private val binding: ItemGitBranchBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: GitBranchListItem.BranchItem) { + val context = binding.root.context + binding.tvBranchName.text = item.displayName + + if (item.branch.isCurrent) { + binding.ivActiveCheck.visibility = View.VISIBLE + binding.imgBranchIcon.visibility = View.GONE + binding.btnMergeAction.visibility = View.GONE + binding.root.contentDescription = context.getString(R.string.current_branch_name, item.displayName) + } else { + binding.ivActiveCheck.visibility = View.GONE + binding.imgBranchIcon.visibility = View.VISIBLE + binding.btnMergeAction.visibility = if (onMergeClicked != null) View.VISIBLE else View.GONE + binding.btnMergeAction.contentDescription = context.getString(R.string.git_merge_branch, item.displayName) + binding.root.contentDescription = item.displayName + } + + binding.btnMergeAction.setOnClickListener { + onMergeClicked?.invoke(item.branch) + } + + binding.root.setOnClickListener { + onBranchSelected(item.branch) + } + } + } + + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: GitBranchListItem, + newItem: GitBranchListItem, + ): Boolean = + when { + oldItem is GitBranchListItem.Header && newItem is GitBranchListItem.Header -> { + oldItem.title == newItem.title + } + + oldItem is GitBranchListItem.BranchItem && newItem is GitBranchListItem.BranchItem -> { + oldItem.branch.fullName == newItem.branch.fullName + } + + else -> { + false + } + } + + override fun areContentsTheSame( + oldItem: GitBranchListItem, + newItem: GitBranchListItem, + ): Boolean = oldItem == newItem + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 988bd198ad..c36f9ab0d3 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -12,11 +12,13 @@ import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.GitRepository import com.itsaky.androidide.git.core.GitRepositoryManager import com.itsaky.androidide.git.core.models.CommitHistoryUiState +import com.itsaky.androidide.git.core.models.GitBranch import com.itsaky.androidide.git.core.models.GitStatus import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.isNetworkConnected +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -26,6 +28,8 @@ import kotlinx.coroutines.launch import org.eclipse.jgit.api.MergeResult.MergeStatus import org.eclipse.jgit.api.PullResult import org.eclipse.jgit.api.errors.CheckoutConflictException +import org.eclipse.jgit.api.errors.InvalidRefNameException +import org.eclipse.jgit.api.errors.RefAlreadyExistsException import org.eclipse.jgit.transport.RemoteRefUpdate import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider import org.greenrobot.eventbus.EventBus @@ -33,10 +37,12 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory import java.io.File +import kotlin.time.Duration.Companion.milliseconds class GitBottomSheetViewModel( private val credentialsManager: GitCredentialsManager, private val isNetworkConnected: () -> Boolean = { BaseApplication.baseInstance.isNetworkConnected() }, + repository: GitRepository? = null, ) : ViewModel() { private val log = LoggerFactory.getLogger(GitBottomSheetViewModel::class.java) @@ -46,6 +52,12 @@ class GitBottomSheetViewModel( private val _currentBranch = MutableStateFlow(null) val currentBranch: StateFlow = _currentBranch.asStateFlow() + private val _branches = MutableStateFlow(BranchesUiState.None) + val branches: StateFlow = _branches.asStateFlow() + + private val _checkoutState = MutableStateFlow(CheckoutUiState.Idle) + val checkoutState: StateFlow = _checkoutState.asStateFlow() + private val _commitHistory = MutableStateFlow(CommitHistoryUiState.Loading) val commitHistory: StateFlow = _commitHistory.asStateFlow() @@ -62,59 +74,186 @@ class GitBottomSheetViewModel( private val _pushState = MutableStateFlow(PushUiState.Idle) val pushState: StateFlow = _pushState.asStateFlow() + private val _mergeState = MutableStateFlow(MergeUiState.Idle) + val mergeState: StateFlow = _mergeState.asStateFlow() + + private var initJob: Job? = null private var pullResetJob: Job? = null private var pushResetJob: Job? = null - var currentRepository: GitRepository? = null + var currentRepository: GitRepository? = repository private set init { EventBus.getDefault().register(this) - initializeRepository() + if (currentRepository == null) { + initializeRepository() + } else { + _isGitRepository.value = true + } } override fun onCleared() { super.onCleared() EventBus.getDefault().unregister(this) + initJob?.cancel() currentRepository?.close() } - private fun initializeRepository() { + /** + * Initializes or re-initializes the Git repository for the currently open project. + * + * When [force] is true or if the repository's root directory does not match the active + * project directory from [IProjectManager], any previously opened repository is closed + * and a new instance is initialized. + */ + fun initializeRepository(force: Boolean = false) { + if (initJob?.isActive == true && !force) { + return + } + initJob?.cancel() + initJob = + viewModelScope.launch { + try { + val projectDirPath = IProjectManager.getInstance().projectDirPath + if (projectDirPath.isNullOrBlank()) { + val previousRepo = currentRepository + currentRepository = null + previousRepo?.close() + _isGitRepository.value = false + _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _branches.value = BranchesUiState.None + _localCommitsCount.value = 0 + return@launch + } + val projectDir = File(projectDirPath) + val currentRoot = currentRepository?.rootDir + if (force || currentRepository == null || currentRoot?.canonicalPath != projectDir.canonicalPath) { + val previousRepo = currentRepository + currentRepository = null + previousRepo?.close() + currentRepository = GitRepositoryManager.openRepository(projectDir) + _isGitRepository.value = currentRepository != null + } + refreshStatus() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to initialize repository", e) + currentRepository = null + _isGitRepository.value = false + _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _branches.value = BranchesUiState.None + _localCommitsCount.value = 0 + } + } + } + + /** + * Refreshes the Git status and branch state of the project. + * Failures while querying branches are handled separately to preserve valid status and commit count. + */ + fun refreshStatus() { viewModelScope.launch { + val repo = currentRepository + if (repo == null) { + _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _branches.value = BranchesUiState.None + _localCommitsCount.value = 0 + return@launch + } + try { - val projectDir = File(IProjectManager.getInstance().projectDirPath) - currentRepository = GitRepositoryManager.openRepository(projectDir) - _isGitRepository.value = currentRepository != null - refreshStatus() + val status = repo.getStatus() + _gitStatus.value = status + _currentBranch.value = repo.getCurrentBranch()?.name + getLocalCommitsCount() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - log.error("Failed to initialize repository", e) - _isGitRepository.value = false + log.error("Failed to refresh git status", e) _gitStatus.value = GitStatus.EMPTY + _currentBranch.value = null + _localCommitsCount.value = 0 + } + + try { + _branches.value = BranchesUiState.Success(repo.getBranches()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to fetch branches during status refresh", e) + _branches.value = BranchesUiState.Error(e.message) } } } /** - * Refreshes the Git status of the project. + * Fetches the list of all local and remote branches from the current repository + * and updates the [_branches] flow with [BranchesUiState]. */ - fun refreshStatus() { + fun fetchBranches() { viewModelScope.launch { + _branches.value = BranchesUiState.Loading try { - currentRepository?.let { repo -> - val status = repo.getStatus() - _gitStatus.value = status - _currentBranch.value = repo.getCurrentBranch()?.name - getLocalCommitsCount() - } ?: run { - _gitStatus.value = GitStatus.EMPTY - _currentBranch.value = null - _localCommitsCount.value = 0 + val repo = currentRepository + if (repo == null) { + _branches.value = BranchesUiState.None + return@launch } + _branches.value = BranchesUiState.Success(repo.getBranches()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - log.error("Failed to refresh git status", e) - _gitStatus.value = GitStatus.EMPTY - _currentBranch.value = null - _localCommitsCount.value = 0 + log.error("Failed to fetch branches", e) + _branches.value = BranchesUiState.Error(e.message) + } + } + } + + /** + * Checks out the given [branchName]. + * + * @param branchName The branch name or remote reference to switch to or create. + * @param createNew If true, creates a new branch. + * @param startPoint Optional start commit or branch name when creating a new branch. + * @param onSuccess Optional callback invoked when the checkout succeeds. + */ + fun checkoutBranch( + branchName: String, + createNew: Boolean = false, + startPoint: String? = null, + onSuccess: (() -> Unit)? = null, + ) { + viewModelScope.launch { + val repository = currentRepository ?: return@launch + _checkoutState.value = CheckoutUiState.CheckingOut + try { + val resolvedBranch = repository.checkout(branchName, createNew, startPoint) + refreshStatus() + _checkoutState.value = CheckoutUiState.Success(resolvedBranch) + onSuccess?.invoke() + } catch (e: CheckoutConflictException) { + log.error("Checkout conflict occurred", e) + _checkoutState.value = CheckoutUiState.Conflicts(e.conflictingPaths ?: emptyList()) + } catch (e: CancellationException) { + throw e + } catch (e: RefAlreadyExistsException) { + log.error("Branch $branchName already exists", e) + _checkoutState.value = + CheckoutUiState.Error( + errorResId = R.string.git_branch_already_exists, + errorArgs = listOf(branchName), + ) + } catch (e: InvalidRefNameException) { + log.error("Invalid branch name $branchName", e) + _checkoutState.value = CheckoutUiState.Error(errorResId = R.string.git_create_branch_invalid_name) + } catch (e: Exception) { + log.error("Checkout failed", e) + _checkoutState.value = CheckoutUiState.Error() } } } @@ -150,6 +289,8 @@ class GitBottomSheetViewModel( refreshStatus() onSuccess() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to commit changes", e) } @@ -167,6 +308,8 @@ class GitBottomSheetViewModel( _commitHistory.value = CommitHistoryUiState.Success(history) } getLocalCommitsCount() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to fetch commit history", e) _commitHistory.value = CommitHistoryUiState.Error(e.message) @@ -205,6 +348,8 @@ class GitBottomSheetViewModel( } handlePushSuccess(username, token) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { if (e.message?.contains("not authorized", ignoreCase = true) == true) { credentialsManager.clearCredentials() @@ -215,7 +360,7 @@ class GitBottomSheetViewModel( } finally { pushResetJob = viewModelScope.launch { - delay(3000) + delay(3000.milliseconds) _pushState.value = PushUiState.Idle } } @@ -281,9 +426,11 @@ class GitBottomSheetViewModel( handlePullSuccess(username, token) } catch (e: CheckoutConflictException) { - log.error("Pull failed with checkout conflict", e) + log.error("Pull checkout conflict occurred", e) val paths = e.conflictingPaths?.joinToString("\n") ?: "" _pullState.value = PullUiState.Error(errorResId = R.string.checkout_conflict_message, errorArgs = listOf(paths)) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Pull failed", e) if (e.message?.contains("not authorized", ignoreCase = true) == true) { @@ -295,7 +442,7 @@ class GitBottomSheetViewModel( } finally { pullResetJob = viewModelScope.launch { - delay(3000) + delay(3000.milliseconds) _pullState.value = PullUiState.Idle } } @@ -334,6 +481,170 @@ class GitBottomSheetViewModel( _pushState.value = PushUiState.Idle } + /** + * Resets [_checkoutState] to [CheckoutUiState.Idle] once the UI has consumed a terminal state. + */ + fun resetCheckoutState() { + _checkoutState.value = CheckoutUiState.Idle + } + + /** + * Resets [_mergeState] to [MergeUiState.Idle] once the UI has consumed a terminal state. + */ + fun resetMergeState() { + _mergeState.value = MergeUiState.Idle + } + + /** + * Merges [targetBranchName] into the currently checked-out branch and updates [_mergeState]. + * + * @param targetBranchName The name of the branch to merge into HEAD. + */ + fun mergeBranch(targetBranchName: String) { + viewModelScope.launch { + val repo = currentRepository ?: return@launch + val currentBranchName = _currentBranch.value ?: "HEAD" + _mergeState.value = MergeUiState.Merging + + try { + val result = repo.merge(targetBranchName) + when (result.mergeStatus) { + MergeStatus.FAST_FORWARD, + MergeStatus.FAST_FORWARD_SQUASHED, + MergeStatus.MERGED, + MergeStatus.MERGED_SQUASHED, + MergeStatus.MERGED_SQUASHED_NOT_COMMITTED, + -> { + _mergeState.value = + MergeUiState.Success( + targetBranch = targetBranchName, + currentBranch = currentBranchName, + ) + refreshStatus() + getCommitHistoryList() + getLocalCommitsCount() + } + + MergeStatus.ALREADY_UP_TO_DATE -> { + _mergeState.value = MergeUiState.AlreadyUpToDate(targetBranch = targetBranchName) + } + + MergeStatus.CONFLICTING -> { + val conflictingFiles = repo.getStatus().conflicted.map { it.path } + _mergeState.value = + MergeUiState.Conflicts( + targetBranch = targetBranchName, + currentBranch = currentBranchName, + conflictingFiles = conflictingFiles, + ) + refreshStatus() + } + + else -> { + log.error("Merge of $targetBranchName ended with status ${result.mergeStatus.name}") + _mergeState.value = + MergeUiState.Error( + targetBranch = targetBranchName, + errorArgs = listOf(targetBranchName), + ) + } + } + } catch (e: CheckoutConflictException) { + log.error("Merge blocked by uncommitted local changes", e) + _mergeState.value = + MergeUiState.Error( + targetBranch = targetBranchName, + errorResId = R.string.git_merge_local_changes, + errorArgs = listOf(e.conflictingPaths?.joinToString("\n") ?: ""), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to merge branch $targetBranchName", e) + _mergeState.value = + MergeUiState.Error( + targetBranch = targetBranchName, + errorArgs = listOf(targetBranchName), + ) + } + } + } + + /** + * Represents the UI state for the repository branches list. + */ + sealed class BranchesUiState { + /** No repository is opened or branch listing has not been initiated. */ + object None : BranchesUiState() + + /** Branches are currently being queried asynchronously from the repository. */ + object Loading : BranchesUiState() + + /** + * Branches were fetched successfully. + * + * @param branches The list of available local and remote branches (can be empty). + */ + data class Success( + val branches: List, + ) : BranchesUiState() + + /** + * An error occurred while discovering or listing repository branches. + * + * @param message Human-readable error description or exception message. + */ + data class Error( + val message: String? = null, + ) : BranchesUiState() + } + + sealed class CheckoutUiState { + object Idle : CheckoutUiState() + + object CheckingOut : CheckoutUiState() + + data class Success( + val branchName: String, + ) : CheckoutUiState() + + data class Conflicts( + val conflictingPaths: List = emptyList(), + ) : CheckoutUiState() + + data class Error( + val errorResId: Int = R.string.git_checkout_failed, + val errorArgs: List? = null, + ) : CheckoutUiState() + } + + sealed class MergeUiState { + object Idle : MergeUiState() + + object Merging : MergeUiState() + + data class Success( + val targetBranch: String, + val currentBranch: String, + ) : MergeUiState() + + data class AlreadyUpToDate( + val targetBranch: String, + ) : MergeUiState() + + data class Conflicts( + val targetBranch: String, + val currentBranch: String, + val conflictingFiles: List = emptyList(), + ) : MergeUiState() + + data class Error( + val targetBranch: String? = null, + val errorResId: Int = R.string.git_merge_failed, + val errorArgs: List? = null, + ) : MergeUiState() + } + sealed class PullUiState { object Idle : PullUiState() @@ -396,6 +707,8 @@ class GitBottomSheetViewModel( currentRepository?.abortMerge() refreshStatus() onSuccess?.invoke() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to abort merge", e) } @@ -409,6 +722,8 @@ class GitBottomSheetViewModel( val projectDir = File(IProjectManager.getInstance().projectDirPath) repository.stageFiles(listOf(File(projectDir, path))) refreshStatus() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.error("Failed to resolve conflict for $path", e) } diff --git a/app/src/main/res/drawable/bg_merge_button.xml b/app/src/main/res/drawable/bg_merge_button.xml new file mode 100644 index 0000000000..cf43d2833e --- /dev/null +++ b/app/src/main/res/drawable/bg_merge_button.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_git_create_branch.xml b/app/src/main/res/layout/dialog_git_create_branch.xml new file mode 100644 index 0000000000..c651293ad2 --- /dev/null +++ b/app/src/main/res/layout/dialog_git_create_branch.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 0fa53d2699..9a7fcf2eac 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -1,203 +1,241 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_git_branch.xml b/app/src/main/res/layout/item_git_branch.xml new file mode 100644 index 0000000000..40b308395b --- /dev/null +++ b/app/src/main/res/layout/item_git_branch.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_git_branch_header.xml b/app/src/main/res/layout/item_git_branch_header.xml new file mode 100644 index 0000000000..8d5cc867f6 --- /dev/null +++ b/app/src/main/res/layout/item_git_branch_header.xml @@ -0,0 +1,15 @@ + + diff --git a/app/src/main/res/layout/popup_git_branches.xml b/app/src/main/res/layout/popup_git_branches.xml new file mode 100644 index 0000000000..5e6c0a021c --- /dev/null +++ b/app/src/main/res/layout/popup_git_branches.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt new file mode 100644 index 0000000000..bf398f28a4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -0,0 +1,248 @@ +package com.itsaky.androidide.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.itsaky.androidide.git.core.GitCredentialsManager +import com.itsaky.androidide.git.core.GitRepository +import com.itsaky.androidide.git.core.models.GitBranch +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.eclipse.jgit.api.MergeResult.MergeStatus +import org.eclipse.jgit.api.errors.CheckoutConflictException +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class GitBottomSheetViewModelTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val credentialsManager = mockk(relaxed = true) + private val repository = mockk(relaxed = true) + private lateinit var viewModel: GitBottomSheetViewModel + + @Before + fun setup() { + viewModel = + GitBottomSheetViewModel( + credentialsManager = credentialsManager, + isNetworkConnected = { true }, + repository = repository, + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `fetchBranches updates branches state`() = + runTest { + val mockBranches = + listOf( + GitBranch(name = "main", fullName = "refs/heads/main", isCurrent = true, isRemote = false), + GitBranch(name = "feature", fullName = "refs/heads/feature", isCurrent = false, isRemote = false), + ) + coEvery { repository.getBranches() } returns mockBranches + + viewModel.fetchBranches() + advanceUntilIdle() + + assertEquals(GitBottomSheetViewModel.BranchesUiState.Success(mockBranches), viewModel.branches.value) + } + + @Test + fun `checkoutBranch success updates checkoutState to Success until consumed`() = + runTest { + coEvery { repository.checkout("feature", false, null) } returns "feature" + coEvery { repository.getStatus() } returns mockk(relaxed = true) + + var successCalled = false + viewModel.checkoutBranch("feature", onSuccess = { successCalled = true }) + testScheduler.advanceTimeBy(100) + + val state = viewModel.checkoutState.value + assertTrue(state is GitBottomSheetViewModel.CheckoutUiState.Success) + assertEquals("feature", (state as GitBottomSheetViewModel.CheckoutUiState.Success).branchName) + assertTrue(successCalled) + coVerify { repository.checkout("feature", false, null) } + + // The terminal state is one-shot: it must survive until the UI consumes it, + // otherwise a view recreation would replay the toast and the file-list refresh. + testScheduler.advanceTimeBy(3000) + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Success) + + viewModel.resetCheckoutState() + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) + } + + @Test + fun `checkoutBranch conflict updates checkoutState to Conflicts until consumed`() = + runTest { + val conflictPaths = listOf("file1.txt", "file2.txt") + val exception = mockk(relaxed = true) + every { exception.conflictingPaths } returns conflictPaths + every { exception.getConflictingPaths() } returns conflictPaths + coEvery { repository.checkout("feature", false, null) } throws exception + + viewModel.checkoutBranch("feature") + testScheduler.advanceTimeBy(100) + + val state = viewModel.checkoutState.value + assertTrue(state is GitBottomSheetViewModel.CheckoutUiState.Conflicts) + assertEquals(conflictPaths, (state as GitBottomSheetViewModel.CheckoutUiState.Conflicts).conflictingPaths) + + testScheduler.advanceTimeBy(3000) + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Conflicts) + + viewModel.resetCheckoutState() + assertTrue(viewModel.checkoutState.value is GitBottomSheetViewModel.CheckoutUiState.Idle) + } + + @Test + fun `mergeBranch success updates mergeState to Success`() = + runTest { + val mergeResult = mockk(relaxed = true) + every { mergeResult.mergeStatus } returns MergeStatus.FAST_FORWARD + coEvery { repository.merge("feature-login") } returns mergeResult + coEvery { repository.getStatus() } returns mockk(relaxed = true) + + viewModel.mergeBranch("feature-login") + testScheduler.advanceTimeBy(100) + + val state = viewModel.mergeState.value + assertTrue(state is GitBottomSheetViewModel.MergeUiState.Success) + assertEquals("feature-login", (state as GitBottomSheetViewModel.MergeUiState.Success).targetBranch) + coVerify { repository.merge("feature-login") } + } + + @Test + fun `mergeBranch already up to date updates mergeState to AlreadyUpToDate`() = + runTest { + val mergeResult = mockk(relaxed = true) + every { mergeResult.mergeStatus } returns MergeStatus.ALREADY_UP_TO_DATE + coEvery { repository.merge("main") } returns mergeResult + + viewModel.mergeBranch("main") + testScheduler.advanceTimeBy(100) + + val state = viewModel.mergeState.value + assertTrue(state is GitBottomSheetViewModel.MergeUiState.AlreadyUpToDate) + assertEquals("main", (state as GitBottomSheetViewModel.MergeUiState.AlreadyUpToDate).targetBranch) + } + + @Test + fun `mergeBranch conflict updates mergeState to Conflicts`() = + runTest { + val mergeResult = mockk(relaxed = true) + every { mergeResult.mergeStatus } returns MergeStatus.CONFLICTING + coEvery { repository.merge("feature-conflict") } returns mergeResult + val mockStatus = mockk(relaxed = true) + every { mockStatus.conflicted } returns + listOf( + com.itsaky.androidide.git.core.models.FileChange( + "conflicted.txt", + com.itsaky.androidide.git.core.models.ChangeType.CONFLICTED, + ), + ) + coEvery { repository.getStatus() } returns mockStatus + + viewModel.mergeBranch("feature-conflict") + testScheduler.advanceTimeBy(100) + + val state = viewModel.mergeState.value + assertTrue(state is GitBottomSheetViewModel.MergeUiState.Conflicts) + val conflictState = state as GitBottomSheetViewModel.MergeUiState.Conflicts + assertEquals("feature-conflict", conflictState.targetBranch) + assertEquals(listOf("conflicted.txt"), conflictState.conflictingFiles) + } + + @Test + fun `mergeBranch error updates mergeState to Error`() = + runTest { + coEvery { repository.merge("non-existent") } throws IllegalArgumentException("Branch not found") + + viewModel.mergeBranch("non-existent") + testScheduler.advanceTimeBy(100) + + val state = viewModel.mergeState.value + assertTrue(state is GitBottomSheetViewModel.MergeUiState.Error) + val errorState = state as GitBottomSheetViewModel.MergeUiState.Error + assertEquals("non-existent", errorState.targetBranch) + assertEquals(com.itsaky.androidide.resources.R.string.git_merge_failed, errorState.errorResId) + assertEquals(listOf("non-existent"), errorState.errorArgs) + } + + @Test + fun `fetchBranches error updates branches state to Error`() = + runTest { + coEvery { repository.getBranches() } throws RuntimeException("Git error") + + viewModel.fetchBranches() + advanceUntilIdle() + + val state = viewModel.branches.value + assertTrue(state is GitBottomSheetViewModel.BranchesUiState.Error) + assertEquals("Git error", (state as GitBottomSheetViewModel.BranchesUiState.Error).message) + } + + @Test + fun `refreshStatus preserves gitStatus when branch fetching fails`() = + runTest { + val mockStatus = mockk(relaxed = true) + coEvery { repository.getStatus() } returns mockStatus + coEvery { repository.getCurrentBranch() } returns GitBranch("main", "refs/heads/main", true, false) + coEvery { repository.getLocalCommitsCount() } returns 2 + coEvery { repository.getBranches() } throws RuntimeException("Branch failure") + + viewModel.refreshStatus() + advanceUntilIdle() + + assertEquals(mockStatus, viewModel.gitStatus.value) + assertEquals("main", viewModel.currentBranch.value) + assertTrue(viewModel.branches.value is GitBottomSheetViewModel.BranchesUiState.Error) + } + + @Test + fun `initializeRepository clears currentRepository when opening fails`() = + runTest { + io.mockk.mockkObject(com.itsaky.androidide.projects.IProjectManager.Companion) + val mockProjectManager = mockk(relaxed = true) + every { mockProjectManager.projectDirPath } returns "/mock/path" + every { + com.itsaky.androidide.projects.IProjectManager + .getInstance() + } returns mockProjectManager + + io.mockk.mockkObject(com.itsaky.androidide.git.core.GitRepositoryManager) + coEvery { + com.itsaky.androidide.git.core.GitRepositoryManager + .openRepository(any()) + } throws RuntimeException("Corrupt repository") + + viewModel.initializeRepository(force = true) + advanceUntilIdle() + + assertEquals(null, viewModel.currentRepository) + assertEquals(false, viewModel.isGitRepository.value) + assertEquals(com.itsaky.androidide.git.core.models.GitStatus.EMPTY, viewModel.gitStatus.value) + assertEquals(null, viewModel.currentBranch.value) + assertEquals(GitBottomSheetViewModel.BranchesUiState.None, viewModel.branches.value) + } +} diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt index 539d98d34b..39ee4c1968 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt @@ -8,42 +8,80 @@ import org.eclipse.jgit.api.PullResult import org.eclipse.jgit.lib.ProgressMonitor import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.PushResult -import java.io.File - import java.io.Closeable +import java.io.File /** * Interface defining core Git repository operations. */ interface GitRepository : Closeable { - val rootDir: File - - suspend fun getStatus(): GitStatus - suspend fun getCurrentBranch(): GitBranch? - suspend fun getBranches(): List - suspend fun getHistory(limit: Int = 50): List - suspend fun getDiff(file: File): String - - // Commit Operations - suspend fun stageFiles(files: List) - suspend fun commit(message: String, authorName: String? = null, authorEmail: String? = null): GitCommit? - - // Push Operations - suspend fun push( - remote: String = "origin", - credentialsProvider: CredentialsProvider? = null, - progressMonitor: ProgressMonitor? = null - ): Iterable - - suspend fun getLocalCommitsCount(): Int - - suspend fun pull( - remote: String = "origin", - credentialsProvider: CredentialsProvider? = null, - progressMonitor: ProgressMonitor? = null - ): PullResult - - // Merge Operations - suspend fun merge(branchName: String): MergeResult - suspend fun abortMerge() + val rootDir: File + + suspend fun getStatus(): GitStatus + + suspend fun getCurrentBranch(): GitBranch? + + suspend fun getBranches(): List + + suspend fun getHistory(limit: Int = 50): List + + suspend fun getDiff(file: File): String + + // Commit Operations + suspend fun stageFiles(files: List) + + suspend fun commit( + message: String, + authorName: String? = null, + authorEmail: String? = null, + ): GitCommit? + + // Push Operations + suspend fun push( + remote: String = "origin", + credentialsProvider: CredentialsProvider? = null, + progressMonitor: ProgressMonitor? = null, + ): Iterable + + suspend fun getLocalCommitsCount(): Int + + suspend fun pull( + remote: String = "origin", + credentialsProvider: CredentialsProvider? = null, + progressMonitor: ProgressMonitor? = null, + ): PullResult + + // Merge Operations + + /** + * Merges the specified branch into the current HEAD branch. + * + * @param branchName The name of the target branch to merge into current HEAD. + * @return [MergeResult] containing the merge status (e.g. FAST_FORWARD, CONFLICTING). + */ + suspend fun merge(branchName: String): MergeResult + + /** + * Aborts an ongoing conflicted merge, resetting the working tree and index back to HEAD. + */ + suspend fun abortMerge() + + // Branch Operations + + /** + * Checks out the specified branch. + * + * When [createNew] is true, creates a new local branch with [branchName] starting from [startPoint] (or HEAD if null). + * When [createNew] is false and [branchName] refers to a remote-tracking branch, creates or switches to a corresponding + * local branch configured to track the remote ref. + * + * @param branchName The target branch name or remote ref (e.g., "main", "feature", "origin/main"). + * @param createNew If true, creates a new branch instead of switching to an existing one. + * @param startPoint Optional start commit or branch name when creating a new branch. + */ + suspend fun checkout( + branchName: String, + createNew: Boolean = false, + startPoint: String? = null, + ): String } diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index acfafaf30b..23a5c05c6c 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -7,9 +7,13 @@ import com.itsaky.androidide.git.core.models.GitCommit import com.itsaky.androidide.git.core.models.GitStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.CheckoutCommand +import org.eclipse.jgit.api.CreateBranchCommand import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.ListBranchCommand.ListMode import org.eclipse.jgit.api.MergeResult +import org.eclipse.jgit.api.PullResult +import org.eclipse.jgit.api.ResetCommand.ResetType import org.eclipse.jgit.diff.DiffFormatter import org.eclipse.jgit.dircache.DirCacheIterator import org.eclipse.jgit.lib.BranchConfig @@ -17,14 +21,12 @@ import org.eclipse.jgit.lib.Constants import org.eclipse.jgit.lib.PersonIdent import org.eclipse.jgit.lib.ProgressMonitor import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.lib.RepositoryState import org.eclipse.jgit.revwalk.RevCommit import org.eclipse.jgit.revwalk.RevWalk import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.PushResult -import org.eclipse.jgit.api.PullResult -import org.eclipse.jgit.api.ResetCommand.ResetType -import org.eclipse.jgit.lib.RepositoryState import org.eclipse.jgit.treewalk.AbstractTreeIterator import org.eclipse.jgit.treewalk.CanonicalTreeParser import org.eclipse.jgit.treewalk.EmptyTreeIterator @@ -37,279 +39,457 @@ import java.io.File /** * JGit-based implementation of the [GitRepository] interface. */ -class JGitRepository(override val rootDir: File) : GitRepository { - - private val log = LoggerFactory.getLogger(JGitRepository::class.java) - - private val repository: Repository = FileRepositoryBuilder() - .setWorkTree(rootDir) - .findGitDir(rootDir) - .build() - - private val git: Git = Git(repository) - - private fun getHeadTree(repository: Repository): AbstractTreeIterator { - val head = repository.resolve(Constants.HEAD) ?: return EmptyTreeIterator() - val treeParser = CanonicalTreeParser() - RevWalk(repository).use { revWalk -> - val commit = revWalk.parseCommit(head) - repository.newObjectReader().use { reader -> - treeParser.reset(reader, commit.tree.id) - } - } - return treeParser - } - - override suspend fun getStatus(): GitStatus = withContext(Dispatchers.IO) { - val jgitStatus = git.status().call() - - val staged = mutableListOf() - val unstaged = mutableListOf() - val untracked = mutableListOf() - val conflicted = mutableListOf() - - // Track unique paths to avoid duplicates across categories - // Priority: Conflicted > Staged > Unstaged > Untracked - val processedPaths = mutableSetOf() - - // 1. Conflicted (Highest Priority) - jgitStatus.conflicting.forEach { - if (processedPaths.add(it)) { - conflicted.add(FileChange(it, ChangeType.CONFLICTED)) - } - } - - // 2. Staged files (Added, Changed, Removed) - jgitStatus.added.forEach { if (processedPaths.add(it)) staged.add(FileChange(it, ChangeType.ADDED)) } - jgitStatus.changed.forEach { if (processedPaths.add(it)) staged.add(FileChange(it, ChangeType.MODIFIED)) } - jgitStatus.removed.forEach { if (processedPaths.add(it)) staged.add(FileChange(it, ChangeType.DELETED)) } - - // 3. Unstaged files (Modified, Missing) - jgitStatus.modified.forEach { if (processedPaths.add(it)) unstaged.add(FileChange(it, ChangeType.MODIFIED)) } - jgitStatus.missing.forEach { if (processedPaths.add(it)) unstaged.add(FileChange(it, ChangeType.DELETED)) } - - // 4. Untracked files - jgitStatus.untracked.forEach { if (processedPaths.add(it)) untracked.add(FileChange(it, ChangeType.UNTRACKED)) } - - - val isMerging = repository.repositoryState == RepositoryState.MERGING - - GitStatus( - isClean = jgitStatus.isClean, - hasConflicts = conflicted.isNotEmpty(), - isMerging = isMerging, - staged = staged, - unstaged = unstaged, - untracked = untracked, - conflicted = conflicted - ) - } - - override suspend fun getCurrentBranch(): GitBranch? = withContext(Dispatchers.IO) { - val head = repository.fullBranch ?: return@withContext null - val shortName = repository.branch ?: head - GitBranch( - name = shortName, - fullName = head, - isCurrent = true, - isRemote = head.startsWith(Constants.R_REMOTES) - ) - } - - override suspend fun getBranches(): List = withContext(Dispatchers.IO) { - val currentBranch = repository.fullBranch - git.branchList().setListMode(ListMode.ALL).call().map { ref -> - GitBranch( - name = Repository.shortenRefName(ref.name), - fullName = ref.name, - isCurrent = ref.name == currentBranch, - isRemote = ref.name.startsWith(Constants.R_REMOTES) - ) - } - } - - override suspend fun getHistory(limit: Int): List = withContext(Dispatchers.IO) { - try { - val branchName = repository.branch ?: return@withContext emptyList() - val trackingBranch = BranchConfig(repository.config, branchName).trackingBranch - - RevWalk(repository).use { walk -> - val remoteCommit = trackingBranch?.let { repository.resolve(it) }?.let { - walk.parseCommit(it) - } - - git.log().setMaxCount(limit).call().map { revCommit -> - val commit = walk.parseCommit(revCommit.id) - val isPushed = remoteCommit?.let { walk.isMergedInto(commit, it) } ?: false - commit.toGitCommit(isPushed) - } - } - } catch (e: Exception) { - log.error("Error fetching commit history", e) - emptyList() - } - } - - override suspend fun getDiff(file: File): String = withContext(Dispatchers.IO) { - val relativePath = file.toRelativeString(rootDir).replace('\\', '/') - val outputStream = ByteArrayOutputStream() - DiffFormatter(outputStream).use { formatter -> - formatter.setRepository(repository) - val indexTree = DirCacheIterator(repository.readDirCache()) - val workingTree = FileTreeIterator(repository) - formatter.pathFilter = PathFilter.create(relativePath) - formatter.format(indexTree, workingTree) - - // If empty, check staged diff - if (outputStream.size() == 0) { - val headTree = getHeadTree(repository) - val freshIndexTree = DirCacheIterator(repository.readDirCache()) - formatter.format(headTree, freshIndexTree) - } - } - outputStream.toString() - } - - override suspend fun stageFiles(files: List) = withContext(Dispatchers.IO) { - val addCommand = git.add() - val rmCommand = git.rm() - var hasAdds = false - var hasRms = false - - files.forEach { file -> - val relativePath = file.toRelativeString(rootDir).replace('\\', '/') - if (file.exists()) { - addCommand.addFilepattern(relativePath) - hasAdds = true - } else { - rmCommand.addFilepattern(relativePath) - hasRms = true - } - } - if (hasAdds) addCommand.call() - if (hasRms) rmCommand.call() - Unit - } - - override suspend fun commit( - message: String, - authorName: String?, - authorEmail: String? - ): GitCommit? = withContext(Dispatchers.IO) { - val commitCommand = git.commit().setMessage(message) - - if (!authorName.isNullOrBlank() && !authorEmail.isNullOrBlank()) { - val author = PersonIdent(authorName, authorEmail) - commitCommand.apply { - setAuthor(author) - setCommitter(author) - } - } - - val revCommit = commitCommand.call() - revCommit?.toGitCommit(false) - } - - private fun RevCommit.toGitCommit(hasBeenPushed: Boolean): GitCommit { - val author = authorIdent - return GitCommit( - hash = name, - shortHash = name.take(7), - authorName = author.name, - authorEmail = author.emailAddress, - message = fullMessage.trim(), - timestamp = author.`when`.time, - parentHashes = parents.map { it.name }, - hasBeenPushed = hasBeenPushed - ) - } - - override suspend fun push( - remote: String, - credentialsProvider: CredentialsProvider?, - progressMonitor: ProgressMonitor? - ): Iterable = withContext(Dispatchers.IO) { - val pushCommand = git.push().setRemote(remote) - - if (credentialsProvider != null) { - pushCommand.setCredentialsProvider(credentialsProvider) - } - - if (progressMonitor != null) { - pushCommand.setProgressMonitor(progressMonitor) - } - - pushCommand.call() - } - - override suspend fun getLocalCommitsCount(): Int = withContext(Dispatchers.IO) { - try { - val branchName = repository.branch ?: return@withContext 0 - val branch = repository.resolve(Constants.HEAD) ?: return@withContext 0 - val config = BranchConfig(repository.config, branchName) - val trackingBranch = config.trackingBranch - val remoteBranch = trackingBranch?.let { repository.resolve(it) } - - RevWalk(repository).use { walk -> - val localCommit = walk.parseCommit(branch) - walk.markStart(localCommit) - - if (remoteBranch != null) { - val remoteCommit = walk.parseCommit(remoteBranch) - walk.markUninteresting(remoteCommit) - } - - var count = 0 - walk.forEach { _ -> - count++ - } - count - } - } catch (e: Exception) { - log.error("Error fetching local commits", e) - 0 - } - } - - override suspend fun pull( - remote: String, - credentialsProvider: CredentialsProvider?, - progressMonitor: ProgressMonitor? - ): PullResult = withContext(Dispatchers.IO) { - val pullCommand = git.pull().setRemote(remote) - - if (credentialsProvider != null) { - pullCommand.setCredentialsProvider(credentialsProvider) - } - - if (progressMonitor != null) { - pullCommand.setProgressMonitor(progressMonitor) - } - - pullCommand.call() - } - - override suspend fun merge(branchName: String): MergeResult = withContext(Dispatchers.IO) { - val branchRef = repository.findRef(branchName) ?: throw IllegalArgumentException("Branch $branchName not found") - git.merge().include(branchRef).call() - } - - override suspend fun abortMerge(): Unit = withContext(Dispatchers.IO) { - // Reset working tree and index to HEAD - git.reset().setMode(ResetType.HARD).setRef(Constants.HEAD).call() - - // Explicitly clear merge-related files to exit the MERGING state - repository.apply { - writeMergeHeads(null) - writeMergeCommitMsg(null) - writeCherryPickHead(null) - writeRevertHead(null) - writeSquashCommitMsg(null) - } - } - - override fun close() { - repository.close() - git.close() - } +class JGitRepository( + override val rootDir: File, +) : GitRepository { + private val log = LoggerFactory.getLogger(JGitRepository::class.java) + + private val repository: Repository = + FileRepositoryBuilder() + .setWorkTree(rootDir) + .findGitDir(rootDir) + .build() + + private val git: Git = Git(repository) + + private fun getHeadTree(repository: Repository): AbstractTreeIterator { + val head = repository.resolve(Constants.HEAD) ?: return EmptyTreeIterator() + val treeParser = CanonicalTreeParser() + RevWalk(repository).use { revWalk -> + val commit = revWalk.parseCommit(head) + repository.newObjectReader().use { reader -> + treeParser.reset(reader, commit.tree.id) + } + } + return treeParser + } + + override suspend fun getStatus(): GitStatus = + withContext(Dispatchers.IO) { + val jgitStatus = git.status().call() + + val staged = mutableListOf() + val unstaged = mutableListOf() + val untracked = mutableListOf() + val conflicted = mutableListOf() + + // Track unique paths to avoid duplicates across categories + // Priority: Conflicted > Staged > Unstaged > Untracked + val processedPaths = mutableSetOf() + + // 1. Conflicted (Highest Priority) + jgitStatus.conflicting.forEach { + if (processedPaths.add(it)) { + conflicted.add(FileChange(it, ChangeType.CONFLICTED)) + } + } + + // 2. Staged files (Added, Changed, Removed) + jgitStatus.added.forEach { if (processedPaths.add(it)) staged.add(FileChange(it, ChangeType.ADDED)) } + jgitStatus.changed.forEach { if (processedPaths.add(it)) staged.add(FileChange(it, ChangeType.MODIFIED)) } + jgitStatus.removed.forEach { if (processedPaths.add(it)) staged.add(FileChange(it, ChangeType.DELETED)) } + + // 3. Unstaged files (Modified, Missing) + jgitStatus.modified.forEach { if (processedPaths.add(it)) unstaged.add(FileChange(it, ChangeType.MODIFIED)) } + jgitStatus.missing.forEach { if (processedPaths.add(it)) unstaged.add(FileChange(it, ChangeType.DELETED)) } + + // 4. Untracked files + jgitStatus.untracked.forEach { if (processedPaths.add(it)) untracked.add(FileChange(it, ChangeType.UNTRACKED)) } + + val isMerging = repository.repositoryState == RepositoryState.MERGING + + GitStatus( + isClean = jgitStatus.isClean, + hasConflicts = conflicted.isNotEmpty(), + isMerging = isMerging, + staged = staged, + unstaged = unstaged, + untracked = untracked, + conflicted = conflicted, + ) + } + + override suspend fun getCurrentBranch(): GitBranch? = + withContext(Dispatchers.IO) { + val head = repository.fullBranch ?: return@withContext null + val shortName = repository.branch ?: head + GitBranch( + name = shortName, + fullName = head, + isCurrent = true, + isRemote = head.startsWith(Constants.R_REMOTES), + ) + } + + override suspend fun getBranches(): List = + withContext(Dispatchers.IO) { + val currentBranch = repository.fullBranch + git + .branchList() + .setListMode(ListMode.ALL) + .call() + .filter { ref -> !ref.name.endsWith("/HEAD") && !ref.isSymbolic } + .map { ref -> + val isRemote = ref.name.startsWith(Constants.R_REMOTES) + val shortName = Repository.shortenRefName(ref.name) + val remoteName = + if (isRemote) { + shortName.substringBefore('/') + } else { + null + } + GitBranch( + name = shortName, + fullName = ref.name, + isCurrent = ref.name == currentBranch, + isRemote = isRemote, + remoteName = remoteName, + ) + } + } + + override suspend fun getHistory(limit: Int): List = + withContext(Dispatchers.IO) { + try { + val branchName = repository.branch ?: return@withContext emptyList() + val trackingBranch = BranchConfig(repository.config, branchName).trackingBranch + + RevWalk(repository).use { walk -> + val remoteCommit = + trackingBranch?.let { repository.resolve(it) }?.let { + walk.parseCommit(it) + } + + git.log().setMaxCount(limit).call().map { revCommit -> + val commit = walk.parseCommit(revCommit.id) + val isPushed = remoteCommit?.let { walk.isMergedInto(commit, it) } ?: false + commit.toGitCommit(isPushed) + } + } + } catch (e: Exception) { + log.error("Error fetching commit history", e) + emptyList() + } + } + + override suspend fun getDiff(file: File): String = + withContext(Dispatchers.IO) { + val relativePath = file.toRelativeString(rootDir).replace('\\', '/') + val outputStream = ByteArrayOutputStream() + DiffFormatter(outputStream).use { formatter -> + formatter.setRepository(repository) + val indexTree = DirCacheIterator(repository.readDirCache()) + val workingTree = FileTreeIterator(repository) + formatter.pathFilter = PathFilter.create(relativePath) + formatter.format(indexTree, workingTree) + + // If empty, check staged diff + if (outputStream.size() == 0) { + val headTree = getHeadTree(repository) + val freshIndexTree = DirCacheIterator(repository.readDirCache()) + formatter.format(headTree, freshIndexTree) + } + } + outputStream.toString() + } + + override suspend fun stageFiles(files: List) = + withContext(Dispatchers.IO) { + val addCommand = git.add() + val rmCommand = git.rm() + var hasAdds = false + var hasRms = false + + files.forEach { file -> + val relativePath = file.toRelativeString(rootDir).replace('\\', '/') + if (file.exists()) { + addCommand.addFilepattern(relativePath) + hasAdds = true + } else { + rmCommand.addFilepattern(relativePath) + hasRms = true + } + } + if (hasAdds) addCommand.call() + if (hasRms) rmCommand.call() + Unit + } + + override suspend fun commit( + message: String, + authorName: String?, + authorEmail: String?, + ): GitCommit? = + withContext(Dispatchers.IO) { + val commitCommand = git.commit().setMessage(message) + + if (!authorName.isNullOrBlank() && !authorEmail.isNullOrBlank()) { + val author = PersonIdent(authorName, authorEmail) + commitCommand.apply { + setAuthor(author) + setCommitter(author) + } + } + + val revCommit = commitCommand.call() + revCommit?.toGitCommit(false) + } + + private fun RevCommit.toGitCommit(hasBeenPushed: Boolean): GitCommit { + val author = authorIdent + return GitCommit( + hash = name, + shortHash = name.take(7), + authorName = author.name, + authorEmail = author.emailAddress, + message = fullMessage.trim(), + timestamp = author.`when`.time, + parentHashes = parents.map { it.name }, + hasBeenPushed = hasBeenPushed, + ) + } + + override suspend fun push( + remote: String, + credentialsProvider: CredentialsProvider?, + progressMonitor: ProgressMonitor?, + ): Iterable = + withContext(Dispatchers.IO) { + val pushCommand = git.push().setRemote(remote) + + if (credentialsProvider != null) { + pushCommand.setCredentialsProvider(credentialsProvider) + } + + if (progressMonitor != null) { + pushCommand.setProgressMonitor(progressMonitor) + } + + pushCommand.call() + } + + override suspend fun getLocalCommitsCount(): Int = + withContext(Dispatchers.IO) { + try { + val branchName = repository.branch ?: return@withContext 0 + val branch = repository.resolve(Constants.HEAD) ?: return@withContext 0 + val config = BranchConfig(repository.config, branchName) + val trackingBranch = config.trackingBranch + val remoteBranch = trackingBranch?.let { repository.resolve(it) } + + RevWalk(repository).use { walk -> + val localCommit = walk.parseCommit(branch) + walk.markStart(localCommit) + + if (remoteBranch != null) { + val remoteCommit = walk.parseCommit(remoteBranch) + walk.markUninteresting(remoteCommit) + } + + var count = 0 + walk.forEach { _ -> + count++ + } + count + } + } catch (e: Exception) { + log.error("Error fetching local commits", e) + 0 + } + } + + override suspend fun pull( + remote: String, + credentialsProvider: CredentialsProvider?, + progressMonitor: ProgressMonitor?, + ): PullResult = + withContext(Dispatchers.IO) { + val pullCommand = git.pull().setRemote(remote) + + if (credentialsProvider != null) { + pullCommand.setCredentialsProvider(credentialsProvider) + } + + if (progressMonitor != null) { + pullCommand.setProgressMonitor(progressMonitor) + } + + pullCommand.call() + } + + override suspend fun merge(branchName: String): MergeResult = + withContext(Dispatchers.IO) { + val branchRef = repository.findRef(branchName) ?: throw IllegalArgumentException("Branch $branchName not found") + git.merge().include(branchRef).call() + } + + override suspend fun abortMerge(): Unit = + withContext(Dispatchers.IO) { + // Reset working tree and index to HEAD + git + .reset() + .setMode(ResetType.HARD) + .setRef(Constants.HEAD) + .call() + + // Explicitly clear merge-related files to exit the MERGING state + repository.apply { + writeMergeHeads(null) + writeMergeCommitMsg(null) + writeCherryPickHead(null) + writeRevertHead(null) + writeSquashCommitMsg(null) + } + } + + override suspend fun checkout( + branchName: String, + createNew: Boolean, + startPoint: String?, + ): String = + withContext(Dispatchers.IO) { + val command = git.checkout() + + if (createNew) { + configureNewBranchCheckout(command, branchName, startPoint) + } else { + configureExistingBranchCheckout(command, branchName) + } + + command.call() + Repository.shortenRefName(repository.fullBranch) + } + + private fun configureNewBranchCheckout( + command: CheckoutCommand, + branchName: String, + startPoint: String?, + ) { + command + .setCreateBranch(true) + .setName(branchName) + + if (!startPoint.isNullOrBlank()) { + command.setStartPoint(startPoint) + } + } + + private fun configureExistingBranchCheckout( + command: CheckoutCommand, + branchName: String, + ) { + val isExistingLocal = repository.findRef("${Constants.R_HEADS}$branchName") != null + val remoteRef = if (isExistingLocal) null else resolveRemoteRef(branchName) + + if (remoteRef == null) { + command.setName(branchName) + return + } + + when (val resolution = resolveLocalBranch(remoteRef)) { + is LocalBranch -> { + command.setName(resolution.name) + } + + is NewBranch -> { + configureNewTrackingBranch( + command, + resolution.name, + remoteRef, + ) + } + } + } + + private fun resolveRemoteRef(branchName: String): String? { + if (branchName.startsWith(Constants.R_REMOTES)) { + return branchName + } + + return "${Constants.R_REMOTES}$branchName" + .takeIf { repository.findRef(it) != null } + } + + private fun resolveLocalBranch(remoteRef: String): BranchResolution { + val shortRemoteName = Repository.shortenRefName(remoteRef) + val localName = shortRemoteName.substringAfter('/') + + repository.findRef("${Constants.R_HEADS}$localName") ?: return NewBranch(localName) + if (isTrackingRemote(localName, remoteRef, shortRemoteName)) { + return LocalBranch(localName) + } + + val scopedName = shortRemoteName.replace('/', '-') + repository.findRef("${Constants.R_HEADS}$scopedName") ?: return NewBranch(scopedName) + if (isTrackingRemote(scopedName, remoteRef, shortRemoteName)) { + return LocalBranch(scopedName) + } + + return NewBranch( + findAvailableBranchName( + scopedName, + remoteRef, + shortRemoteName, + ), + ) + } + + private fun isTrackingRemote( + name: String, + remoteRef: String, + shortRemoteName: String, + ): Boolean { + val trackingBranch = BranchConfig(repository.config, name).trackingBranch + + return trackingBranch == null || + trackingBranch == remoteRef || + trackingBranch == shortRemoteName + } + + private fun findAvailableBranchName( + baseName: String, + remoteRef: String, + shortRemoteName: String, + ): String { + var suffix = 1 + var candidate = "$baseName-$suffix" + + while (repository.findRef("${Constants.R_HEADS}$candidate") != null) { + if (isTrackingRemote(candidate, remoteRef, shortRemoteName)) { + return candidate + } + + suffix++ + candidate = "$baseName-$suffix" + } + + return candidate + } + + private fun configureNewTrackingBranch( + command: CheckoutCommand, + name: String, + remoteRef: String, + ) { + command + .setCreateBranch(true) + .setName(name) + .setStartPoint(remoteRef) + .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) + } + + private sealed interface BranchResolution + + private data class LocalBranch( + val name: String, + ) : BranchResolution + + private data class NewBranch( + val name: String, + ) : BranchResolution + + override fun close() { + repository.close() + git.close() + } } diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt new file mode 100644 index 0000000000..4d7345fe3b --- /dev/null +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -0,0 +1,243 @@ +package com.itsaky.androidide.git.core + +import kotlinx.coroutines.runBlocking +import org.eclipse.jgit.api.Git +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Unit tests verifying [JGitRepository] core operations and contracts. + * + * Each test executes against an isolated disposable Git repository created in [TemporaryFolder]. + * Verifies that branch checkout resolves local and remote tracking refs correctly, + * handles naming collisions cleanly, and ensures that aborting a merge completely restores + * the pre-merge working tree state back to HEAD. + */ +class JGitRepositoryTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var repoDir: File + private lateinit var jgitRepo: JGitRepository + + @Before + fun setUp() { + repoDir = tempFolder.newFolder("test-repo") + + // Create an initial commit so HEAD points to a valid commit + val dummyFile = File(repoDir, "file.txt") + dummyFile.writeText("initial content") + + Git.init().setDirectory(repoDir).call().use { git -> + git.add().addFilepattern("file.txt").call() + git + .commit() + .setMessage("Initial commit") + .setAuthor("Test", "test@example.com") + .call() + } + + jgitRepo = JGitRepository(repoDir) + } + + @After + fun tearDown() { + jgitRepo.close() + } + + @Test + fun testGetCurrentBranchAndGetBranches() = + runBlocking { + val currentBranch = jgitRepo.getCurrentBranch() + assertNotNull(currentBranch) + assertTrue(currentBranch!!.isCurrent) + + val branches = jgitRepo.getBranches() + assertFalse(branches.isEmpty()) + assertTrue(branches.any { it.isCurrent }) + } + + @Test + fun testCreateAndCheckoutBranch() = + runBlocking { + val newBranchName = "feature-test" + jgitRepo.checkout(newBranchName, createNew = true) + + val currentBranch = jgitRepo.getCurrentBranch() + assertNotNull(currentBranch) + assertEquals(newBranchName, currentBranch!!.name) + + val branches = jgitRepo.getBranches() + assertTrue(branches.any { it.name == newBranchName && it.isCurrent }) + } + + @Test + fun testSwitchExistingBranches() = + runBlocking { + val initialBranch = jgitRepo.getCurrentBranch()!!.name + + // Create feature branch + jgitRepo.checkout("feature-1", createNew = true) + assertEquals("feature-1", jgitRepo.getCurrentBranch()!!.name) + + // Switch back to initial branch + jgitRepo.checkout(initialBranch, createNew = false) + assertEquals(initialBranch, jgitRepo.getCurrentBranch()!!.name) + } + + @Test + fun testMergeFastForward() = + runBlocking { + val initialBranch = jgitRepo.getCurrentBranch()!!.name + + // Create feature branch and make a commit + jgitRepo.checkout("feature-merge", createNew = true) + val featureFile = File(repoDir, "feature.txt") + featureFile.writeText("feature content") + jgitRepo.stageFiles(listOf(featureFile)) + jgitRepo.commit("Feature commit", "Test", "test@example.com") + + // Switch back to initial branch and merge + jgitRepo.checkout(initialBranch, createNew = false) + val result = jgitRepo.merge("feature-merge") + assertTrue(result.mergeStatus.isSuccessful) + assertTrue(File(repoDir, "feature.txt").exists()) + } + + @Test + fun testAbortMerge() = + runBlocking { + val initialBranch = jgitRepo.getCurrentBranch()!!.name + + // Create feature branch and change file.txt + jgitRepo.checkout("feature-conflict", createNew = true) + val file = File(repoDir, "file.txt") + file.writeText("feature conflict content") + jgitRepo.stageFiles(listOf(file)) + jgitRepo.commit("Feature conflict commit", "Test", "test@example.com") + + // Switch to initial branch and make a conflicting change + jgitRepo.checkout(initialBranch, createNew = false) + file.writeText("initial conflicting content") + jgitRepo.stageFiles(listOf(file)) + jgitRepo.commit("Main conflicting commit", "Test", "test@example.com") + + // Attempt merge -> CONFLICTING + val mergeResult = jgitRepo.merge("feature-conflict") + assertEquals(org.eclipse.jgit.api.MergeResult.MergeStatus.CONFLICTING, mergeResult.mergeStatus) + val statusBeforeAbort = jgitRepo.getStatus() + assertTrue(statusBeforeAbort.isMerging) + assertTrue(statusBeforeAbort.hasConflicts) + + // Abort merge -> verify clean state + jgitRepo.abortMerge() + val statusAfterAbort = jgitRepo.getStatus() + assertFalse(statusAfterAbort.isMerging) + assertFalse(statusAfterAbort.hasConflicts) + assertEquals("initial conflicting content", file.readText()) + } + + @Test + fun testCheckoutRemoteTrackingBranch() = + runBlocking { + // Manually create a remote ref refs/remotes/origin/release + val headCommit = + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + repo.resolve(org.eclipse.jgit.lib.Constants.HEAD) + } + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + val refUpdate = repo.updateRef("refs/remotes/origin/release") + refUpdate.setNewObjectId(headCommit) + refUpdate.update() + } + + // Checkout remote branch -> should create local branch "release" + jgitRepo.checkout("origin/release", createNew = false) + val currentBranch = jgitRepo.getCurrentBranch() + assertNotNull(currentBranch) + assertEquals("release", currentBranch!!.name) + + // Now create another remote ref with the same short name under a different remote "upstream/release" + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + val refUpdate = repo.updateRef("refs/remotes/upstream/release") + refUpdate.setNewObjectId(headCommit) + refUpdate.update() + } + + // Checkout upstream/release -> local "release" exists and tracks origin/release, so it should create "upstream-release" + jgitRepo.checkout("upstream/release", createNew = false) + val newCurrentBranch = jgitRepo.getCurrentBranch() + assertNotNull(newCurrentBranch) + assertEquals("upstream-release", newCurrentBranch!!.name) + + // Now test when "upstream-release" already exists and we check out upstream/release again + jgitRepo.checkout("origin/release", createNew = false) + assertEquals("release", jgitRepo.getCurrentBranch()?.name) + + // Checking out upstream/release should reuse upstream-release because it tracks upstream/release + jgitRepo.checkout("upstream/release", createNew = false) + assertEquals("upstream-release", jgitRepo.getCurrentBranch()?.name) + } + + @Test + fun testGetBranchesFiltersOutOriginHead() = + runBlocking { + val headCommit = + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + repo.resolve(org.eclipse.jgit.lib.Constants.HEAD) + } + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + // Create refs/remotes/origin/main + val refMain = repo.updateRef("refs/remotes/origin/main") + refMain.setNewObjectId(headCommit) + refMain.update() + + // Create symbolic or direct refs/remotes/origin/HEAD + val refHead = repo.updateRef("refs/remotes/origin/HEAD") + refHead.setNewObjectId(headCommit) + refHead.update() + } + + val branches = jgitRepo.getBranches() + val branchNames = branches.map { it.name } + assertTrue(branchNames.contains("origin/main")) + assertFalse("origin/HEAD should be filtered out", branchNames.contains("origin/HEAD")) + assertFalse("refs/remotes/origin/HEAD should be filtered out", branches.any { it.fullName.endsWith("/HEAD") }) + } + + @Test + fun testCheckoutRemoteReusesUntrackedLocalBranch() = + runBlocking { + val initialBranch = jgitRepo.getCurrentBranch()!!.name + + // A branch created locally has no upstream configured + jgitRepo.checkout("release", createNew = true) + jgitRepo.checkout(initialBranch, createNew = false) + + val headCommit = + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + repo.resolve(org.eclipse.jgit.lib.Constants.HEAD) + } + org.eclipse.jgit.storage.file.FileRepositoryBuilder().setWorkTree(repoDir).findGitDir(repoDir).build().use { repo -> + val refUpdate = repo.updateRef("refs/remotes/origin/release") + refUpdate.setNewObjectId(headCommit) + refUpdate.update() + } + + // The untracked local "release" is the counterpart, so reuse it instead of forking "origin-release" + jgitRepo.checkout("origin/release", createNew = false) + assertEquals("release", jgitRepo.getCurrentBranch()!!.name) + assertFalse( + "origin-release should not be created", + jgitRepo.getBranches().any { it.name == "origin-release" }, + ) + } +} diff --git a/resources/src/main/res/drawable/ic_branch.xml b/resources/src/main/res/drawable/ic_branch.xml new file mode 100644 index 0000000000..9177970862 --- /dev/null +++ b/resources/src/main/res/drawable/ic_branch.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_merge.xml b/resources/src/main/res/drawable/ic_merge.xml new file mode 100644 index 0000000000..bda7d9579c --- /dev/null +++ b/resources/src/main/res/drawable/ic_merge.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index d02d5683c9..6bdd55490b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1464,6 +1464,22 @@ Not set This project is not a Git repository Current branch: %1$s + Current branch + Changed files: %1$d + Branches + Local + Remote + Create branch + Create new branch + Branch name + Search branches… + Switched to branch \'%1$s\' + Failed to switch branch + Checkout conflict + Cannot switch branch because uncommitted changes would be overwritten. Please commit or stash your changes before switching branches.\n\nConflicting files:\n%1$s + Please enter a valid branch name + A branch named %1$s already exists + Could not load branches Push Pushing… Push successful! @@ -1481,12 +1497,26 @@ Merge conflicts Abort merge Are you sure you want to abort the current merge? All conflict resolutions will be discarded. + Merge %1$s into current branch + No branches found + Merged %1$s into %2$s successfully + Merge failed + Failed to merge %1$s + Merge conflict + Conflicts occurred while merging %1$s into %2$s. Please resolve conflicts or abort merge. + Cannot merge because you have uncommitted changes that would be overwritten. Commit or discard them first.\n\nAffected files:\n%1$s + Already up to date You have unsaved changes. Would you like to save them before proceeding? Proceed without saving Save before proceeding Mark resolved Check All Uncheck All + Merge branch? + You are about to merge \'%1$s\' into \'%2$s\'. Are you sure you want to proceed? + Proceed + Switch branch? + You are about to check out branch \'%1$s\'. Are you sure you want to proceed? Starting project creation for %1$s diff --git a/resources/src/main/res/values/styles.xml b/resources/src/main/res/values/styles.xml index 14ab5e2a05..4b0db344b6 100755 --- a/resources/src/main/res/values/styles.xml +++ b/resources/src/main/res/values/styles.xml @@ -35,6 +35,7 @@ @color/md_theme_light_onSurface @color/md_theme_light_surfaceVariant @color/md_theme_light_onSurfaceVariant + @color/white @color/md_theme_light_inverseSurface @color/md_theme_light_inverseOnSurface @color/md_theme_light_inversePrimary