Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -279,25 +279,28 @@ open class EditorHandlerActivity :
* [onPause] snapshot **and** the in-memory buffer is still clean ([CodeEditorView.isModified] is
* false). A clean buffer may still have undo history after [IDEEditor.markUnmodified] / save; we
* reload anyway so external edits are not ignored. Never replaces buffers with unsaved edits.
*
* @param force If true, reloads even if the buffer is modified or the timestamp hasn't changed.
*/
private fun checkForExternalFileChanges() {
fun checkForExternalFileChanges(force: Boolean = false) {
val openFiles = editorViewModel.getOpenedFiles()
if (openFiles.isEmpty() || fileTimestamps.isEmpty()) return
if (openFiles.isEmpty() || (fileTimestamps.isEmpty() && !force)) return

lifecycleScope.launch(Dispatchers.IO) {
openFiles.forEach { file ->
val lastKnownTimestamp = fileTimestamps[file.absolutePath] ?: return@forEach
val lastKnownTimestamp = fileTimestamps[file.absolutePath] ?: 0L
val currentTimestamp = file.lastModified()

if (currentTimestamp > lastKnownTimestamp) {
if (currentTimestamp > lastKnownTimestamp || force) {
val newContent = runCatching { file.readText() }.getOrNull() ?: return@forEach
withContext(Dispatchers.Main) {
val editorView = getEditorForFile(file) ?: return@withContext
if (editorView.isModified) return@withContext
if (editorView.isModified && !force) return@withContext
val ideEditor = editorView.editor ?: return@withContext

ideEditor.setText(newContent)
editorView.markAsSaved()
fileTimestamps[file.absolutePath] = currentTimestamp
updateTabs()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,31 @@ import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
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.itsaky.androidide.R
import com.itsaky.androidide.activities.PreferencesActivity
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
import com.itsaky.androidide.databinding.DialogGitCredentialsBinding
import com.itsaky.androidide.databinding.FragmentGitBottomSheetBinding
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.preferences.internal.GitPreferences
import com.itsaky.androidide.utils.flashSuccess
import com.itsaky.androidide.viewmodel.BottomSheetViewModel
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel
import com.itsaky.androidide.viewmodel.GitBottomSheetViewModel.PullUiState
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
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

Expand All @@ -43,9 +50,27 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {

fileChangeAdapter = GitFileChangeAdapter(
onFileClicked = { change ->
// Show diff in a dialog when changed file is clicked
val dialog = GitDiffViewerDialog.newInstance(change.path)
dialog.show(childFragmentManager, "GitDiffViewerDialog")
when (change.type) {
ChangeType.CONFLICTED -> {
// Open conflicted file in editor
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 -> {
// Show diff in a dialog when changed file is clicked
val dialog = GitDiffViewerDialog.newInstance(change.path)
dialog.show(childFragmentManager, "GitDiffViewerDialog")
}
}
},
onSelectionChanged = {
validateCommitButton()
Expand Down Expand Up @@ -81,6 +106,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
commitSection.visibility = View.GONE
authorWarning.visibility = View.GONE
commitHistoryButton.visibility = View.GONE
btnAbortMerge.visibility = View.GONE
}
allChanges.isEmpty() -> binding.apply {
emptyView.visibility = View.VISIBLE
Expand All @@ -89,6 +115,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
commitSection.visibility = View.GONE
authorWarning.visibility = View.GONE
commitHistoryButton.visibility = View.VISIBLE
btnAbortMerge.visibility = View.GONE
}
else -> {
binding.apply {
Expand All @@ -97,6 +124,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
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)
}
Expand Down Expand Up @@ -134,6 +162,22 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
binding.commitSummary.doAfterTextChanged { validateCommitButton() }
binding.commitDescription.doAfterTextChanged { validateCommitButton() }

binding.btnAbortMerge.setOnClickListener {
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)
.show()
}

binding.authorAvatar.setOnClickListener {
showAuthorPopup()
}
Expand Down Expand Up @@ -214,25 +258,38 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.pullState.collectLatest { state ->
when (state) {
is GitBottomSheetViewModel.PullUiState.Idle -> {
is PullUiState.Idle -> {
binding.btnPull.isEnabled = true
binding.pullProgress.visibility = View.GONE
}
is GitBottomSheetViewModel.PullUiState.Pulling -> {
is PullUiState.Pulling -> {
binding.btnPull.isEnabled = false
binding.pullProgress.visibility = View.VISIBLE
}
is GitBottomSheetViewModel.PullUiState.Success -> {
is PullUiState.Success -> {
binding.btnPull.isEnabled = true
binding.pullProgress.visibility = View.GONE
flashSuccess(R.string.pull_successful)
viewModel.resetPullState()
}
is GitBottomSheetViewModel.PullUiState.Error -> {
is PullUiState.Conflicts -> {
binding.btnPull.isEnabled = true
binding.pullProgress.visibility = View.GONE
val message = state.message ?: getString(R.string.info_merge_conflicts)
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.merge_conflicts))
.setMessage(message)
.setPositiveButton(android.R.string.ok, null)
.show()
viewModel.resetPullState()
}
is PullUiState.Error -> {
binding.btnPull.isEnabled = true
binding.pullProgress.visibility = View.GONE
val message =
state.errorResId?.let { getString(it) } ?: state.message
state.message ?: state.errorResId?.let { resId ->
if (state.errorArgs != null) getString(resId, *state.errorArgs.toTypedArray()) else getString(resId)
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.pull_failed)
.setMessage(message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,11 @@ class GitCommitHistoryDialog : DialogFragment() {
is GitBottomSheetViewModel.PushUiState.Error -> {
binding.btnPush.isEnabled = true
binding.pushProgress.visibility = View.GONE
val message =
state.errorResId?.let { getString(it) } ?: state.message
val message = if (state.errorResId != null && state.errorResId != R.string.unknown_error) {
getString(state.errorResId)
} else {
state.message ?: getString(R.string.unknown_error)
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.push_failed)
.setMessage(message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.eclipse.jgit.api.MergeResult.MergeStatus
import org.eclipse.jgit.api.PullResult
import org.eclipse.jgit.errors.NoRemoteRepositoryException
import org.eclipse.jgit.api.errors.CheckoutConflictException
import org.eclipse.jgit.transport.RemoteRefUpdate
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider
import org.greenrobot.eventbus.EventBus
Expand Down Expand Up @@ -219,8 +221,14 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana
} else null

private fun handlePushError(update: RemoteRefUpdate) {
val resId = if (update.status == RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD) {
R.string.push_rejected_nonfastforward
} else {
R.string.unknown_error
}
_pushState.value = PushUiState.Error(
update.message ?: update.status.name
message = update.message ?: update.status.name,
errorResId = resId
)
}

Expand Down Expand Up @@ -256,6 +264,10 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana
}

handlePullSuccess(username, token)
} catch (e: CheckoutConflictException) {
log.error("Pull failed with checkout conflict", e)
val paths = e.conflictingPaths?.joinToString("\n") ?: ""
_pullState.value = PullUiState.Error(errorResId = R.string.checkout_conflict_message, errorArgs = listOf(paths))
} catch (e: Exception) {
log.error("Pull failed", e)
if (e.message?.contains("not authorized", ignoreCase = true) == true) {
Expand All @@ -274,8 +286,15 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana
}

private fun handlePullError(result: PullResult) {
val status = result.mergeResult?.mergeStatus?.name ?: "Unknown error"
_pullState.value = PullUiState.Error("Pull failed: $status")
val mergeStatus = result.mergeResult?.mergeStatus
val statusName = mergeStatus?.name ?: "Unknown error"

if (mergeStatus == MergeStatus.CONFLICTING) {
_pullState.value = PullUiState.Conflicts()
refreshStatus()
} else {
_pullState.value = PullUiState.Error("Pull failed: $statusName")
}
}

private fun handlePullSuccess(
Expand All @@ -302,7 +321,8 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana
object Idle : PullUiState()
object Pulling : PullUiState()
object Success : PullUiState()
data class Error(val message: String? = null, val errorResId: Int? = R.string.unknown_error) : PullUiState()
data class Conflicts(val message: String? = null) : PullUiState()
data class Error(val message: String? = null, val errorResId: Int? = R.string.unknown_error, val errorArgs: List<String>? = null) : PullUiState()
}

sealed class PushUiState {
Expand Down Expand Up @@ -337,4 +357,16 @@ class GitBottomSheetViewModel(private val credentialsManager: GitCredentialsMana
refreshStatus()
}

fun abortMerge(onSuccess: (() -> Unit)? = null) {
viewModelScope.launch {
try {
currentRepository?.abortMerge()
refreshStatus()
onSuccess?.invoke()
} catch (e: Exception) {
log.error("Failed to abort merge", e)
}
}
}

}
14 changes: 13 additions & 1 deletion app/src/main/res/layout/fragment_git_bottom_sheet.xml
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@
android:layout_marginBottom="8dp"
android:enabled="false"
android:text="@string/commit"
app:layout_constraintBottom_toTopOf="@id/commitHistoryButton" />
app:layout_constraintBottom_toTopOf="@id/btnAbortMerge" />

<androidx.constraintlayout.widget.Group
android:id="@+id/commit_section"
Expand All @@ -149,6 +149,18 @@
android:visibility="gone"
app:constraint_referenced_ids="authorAvatar, commitSummaryLayout, commitDescriptionLayout, commitButton" />

<com.google.android.material.button.MaterialButton
android:id="@+id/btnAbortMerge"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/abort_merge"
android:textColor="?attr/colorError"
app:strokeColor="?attr/colorError"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/commitHistoryButton" />

<com.google.android.material.button.MaterialButton
android:id="@+id/commitHistoryButton"
android:layout_width="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.itsaky.androidide.git.core
import com.itsaky.androidide.git.core.models.GitBranch
import com.itsaky.androidide.git.core.models.GitCommit
import com.itsaky.androidide.git.core.models.GitStatus
import org.eclipse.jgit.api.MergeResult
import org.eclipse.jgit.api.PullResult
import org.eclipse.jgit.lib.ProgressMonitor
import org.eclipse.jgit.transport.CredentialsProvider
Expand Down Expand Up @@ -41,4 +42,8 @@ interface GitRepository : Closeable {
credentialsProvider: CredentialsProvider? = null,
progressMonitor: ProgressMonitor? = null
): PullResult

// Merge Operations
suspend fun merge(branchName: String): MergeResult
suspend fun abortMerge()
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.ListBranchCommand.ListMode
import org.eclipse.jgit.api.MergeResult
import org.eclipse.jgit.diff.DiffFormatter
import org.eclipse.jgit.dircache.DirCacheIterator
import org.eclipse.jgit.lib.BranchConfig
Expand All @@ -22,6 +23,8 @@ 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
Expand Down Expand Up @@ -76,11 +79,12 @@ class JGitRepository(override val rootDir: File) : GitRepository {

jgitStatus.conflicting.forEach { conflicted.add(FileChange(it, ChangeType.CONFLICTED)) }


val isMerging = repository.repositoryState == RepositoryState.MERGING

GitStatus(
isClean = jgitStatus.isClean,
hasConflicts = jgitStatus.conflicting.isNotEmpty(),
isMerging = isMerging,
staged = staged,
unstaged = unstaged,
untracked = untracked,
Expand Down Expand Up @@ -272,6 +276,25 @@ class JGitRepository(override val rootDir: File) : GitRepository {
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.ORIG_HEAD).call()
Comment on lines +284 to +286
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard abortMerge() behind the actual merge state.

This hard-resets to ORIG_HEAD even when no merge is in progress. A stray call here can roll the repo back to an unrelated previous head and discard work. Please gate the reset/cleanup on repository.repositoryState == RepositoryState.MERGING (or fail fast) before touching refs or the worktree.

Suggested fix
 override suspend fun abortMerge(): Unit = withContext(Dispatchers.IO) {
+    if (repository.repositoryState != RepositoryState.MERGING) {
+        return@withContext
+    }
+
     // Reset working tree and index to HEAD
     git.reset().setMode(ResetType.HARD).setRef(Constants.ORIG_HEAD).call()
     
     // Explicitly clear merge-related files to exit the MERGING state
     repository.apply {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override suspend fun abortMerge(): Unit = withContext(Dispatchers.IO) {
// Reset working tree and index to HEAD
git.reset().setMode(ResetType.HARD).setRef(Constants.ORIG_HEAD).call()
override suspend fun abortMerge(): Unit = withContext(Dispatchers.IO) {
if (repository.repositoryState != RepositoryState.MERGING) {
return@withContext
}
// Reset working tree and index to HEAD
git.reset().setMode(ResetType.HARD).setRef(Constants.ORIG_HEAD).call()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt`
around lines 284 - 286, abortMerge currently always performs a hard reset to
Constants.ORIG_HEAD via git.reset() which can discard work when no merge is in
progress; update the abortMerge() implementation to first check
repository.repositoryState and only proceed if it equals RepositoryState.MERGING
(otherwise return immediately or throw a clear exception), and then perform the
reset/cleanup
(git.reset().setMode(ResetType.HARD).setRef(Constants.ORIG_HEAD).call()) — use
repository.repositoryState == RepositoryState.MERGING to gate the operation to
avoid touching refs/worktree when not merging.


// 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()
Expand Down
Loading
Loading