Commit Diff


commit - 1723d2ac5388915746d7cd9c84279116898690a1
commit + f980e76321979183bd8dd6cc67f93d08fced21db
blob - 59801df9877633c858b4d08ab598a6e0a8b2799d
blob + 8e3da498cbe96662aeccc2cac5fc8e9c46a5e20e
--- build.gradle.kts
+++ build.gradle.kts
@@ -32,6 +32,11 @@ dependencies {
         bundledModule("intellij.platform.vcs.dvcs.impl")
         // Referenced by OutgoingResult's commit list type (com.intellij.dvcs.push).
         bundledModule("intellij.platform.vcs.log")
+        // Hash/VcsUser (vcs.shared), HashImpl/VcsUserImpl (vcs.impl.shared) and
+        // GraphCommit (vcs.log.graph) back the outgoing-commits VcsFullCommitDetails.
+        bundledModule("intellij.platform.vcs.shared")
+        bundledModule("intellij.platform.vcs.impl.shared")
+        bundledModule("intellij.platform.vcs.log.graph")
     }
 }
 
blob - 061b30b9cccd625922be97ff4080fa8d96371d65
blob + 7843f1287cfd7e333e454184ebb7c2696aa9b7c5
--- src/main/kotlin/dev/nezzontli/gotvcs/cli/GotCommandLineWrapper.kt
+++ src/main/kotlin/dev/nezzontli/gotvcs/cli/GotCommandLineWrapper.kt
@@ -14,6 +14,16 @@ data class GotLogEntry(val commitId: String, val autho
 
 data class GotUpdateEntry(val code: Char, val path: String)
 
+data class GotCommitObject(
+    val commitId: String,
+    val parents: List<String>,
+    val authorName: String,
+    val authorEmail: String,
+    /** Seconds since the epoch, as stored in the commit object. */
+    val authorTimestamp: Long,
+    val message: String,
+)
+
 /**
  * Central entry point for every invocation of the `got` binary. The binary
  * path and SSH_AUTH_SOCK are configurable in Settings > Version Control >
@@ -181,4 +191,64 @@ class GotCommandLineWrapper {
             .map { line -> GotUpdateEntry(line[0], line.substring(3)) }
             .toList()
     }
+
+    /** Resolves `refs/remotes/<remote>/<branch>` to a commit ID via `got ref -l`, or null if it doesn't exist locally. */
+    @Throws(VcsException::class)
+    fun remoteBranchHash(workDir: File, remote: String, branch: String): String? {
+        val output = run(workDir, "ref", "-l")
+        val prefix = "refs/remotes/$remote/$branch:"
+        val line = output.lineSequence().firstOrNull { it.trim().startsWith(prefix) } ?: return null
+        return line.substringAfter(":").trim()
+    }
+
+    /**
+     * Commit IDs reachable from the current branch but not yet known to be on
+     * [remoteHash] (via `got log -x`), most recent first. If [remoteHash] is
+     * null (no local knowledge of the remote branch, e.g. before any fetch),
+     * this falls back to the full history up to [limit].
+     */
+    @Throws(VcsException::class)
+    fun outgoingCommitIds(workDir: File, remoteHash: String?, limit: Int = 200): List<String> {
+        val args = mutableListOf("log", "-l", limit.toString())
+        if (remoteHash != null) {
+            args.add("-x")
+            args.add(remoteHash)
+        }
+        val output = run(workDir, *args.toTypedArray())
+        return output.lineSequence()
+            .filter { it.startsWith("commit ") }
+            .map { it.removePrefix("commit ").trim().substringBefore(' ') }
+            .filter { it != remoteHash }
+            .toList()
+    }
+
+    private val commitAuthorPattern = Regex("""^(.*)\s+<(.+)>\s+(\d+)\s+[+-]\d{4}$""")
+
+    /** Parses the raw commit object printed by `got cat <commit-id>` (tree/parent/author/committer/message). */
+    @Throws(VcsException::class)
+    fun catCommit(workDir: File, commitId: String): GotCommitObject {
+        val lines = run(workDir, "cat", commitId).lines()
+        val parents = mutableListOf<String>()
+        var authorName = ""
+        var authorEmail = ""
+        var authorTimestamp = 0L
+        var messageStart = -1
+
+        for ((index, line) in lines.withIndex()) {
+            when {
+                line.startsWith("parent ") -> parents.add(line.removePrefix("parent ").trim())
+                line.startsWith("author ") -> {
+                    commitAuthorPattern.find(line.removePrefix("author ").trim())?.let { match ->
+                        authorName = match.groupValues[1].trim()
+                        authorEmail = match.groupValues[2]
+                        authorTimestamp = match.groupValues[3].toLongOrNull() ?: 0L
+                    }
+                }
+                line.startsWith("messagelen ") -> messageStart = index + 2
+            }
+        }
+
+        val message = if (messageStart in lines.indices) lines.drop(messageStart).joinToString("\n").trimEnd('\n') else ""
+        return GotCommitObject(commitId, parents, authorName, authorEmail, authorTimestamp, message)
+    }
 }
blob - a2df57f27c8576ff4321027caeb61a6e2c2217dc
blob + 632600d4ab0b4ba9d133197386db363c491e6c69
--- src/main/kotlin/dev/nezzontli/gotvcs/push/GotOutgoingCommitsProvider.kt
+++ src/main/kotlin/dev/nezzontli/gotvcs/push/GotOutgoingCommitsProvider.kt
@@ -3,18 +3,35 @@ package dev.nezzontli.gotvcs.push
 import com.intellij.dvcs.push.OutgoingCommitsProvider
 import com.intellij.dvcs.push.OutgoingResult
 import com.intellij.dvcs.push.PushSpec
+import com.intellij.dvcs.push.VcsError
+import com.intellij.openapi.vcs.VcsException
+import dev.nezzontli.gotvcs.cli.GotCommandLineWrapper
 import dev.nezzontli.gotvcs.repo.GotRepository
+import java.io.File
 
 /**
- * got has no cheap local equivalent of "commits not yet on the remote"
- * without contacting the server first, so this reports an empty preview
- * rather than an inaccurate one. The Push dialog still works; it just
- * doesn't list outgoing commits ahead of time.
+ * Lists commits reachable from the current branch but not yet known to be on
+ * `refs/remotes/<remote>/<branch>` locally (via `got log -x`). This reflects
+ * the last `got fetch`, not a live round-trip to the server, same as Git's
+ * own outgoing-commits preview.
  */
-class GotOutgoingCommitsProvider : OutgoingCommitsProvider<GotRepository, GotPushSource, GotPushTarget>() {
+class GotOutgoingCommitsProvider(private val commandLine: GotCommandLineWrapper) :
+    OutgoingCommitsProvider<GotRepository, GotPushSource, GotPushTarget>() {
+
     override fun getOutgoingCommits(
         repository: GotRepository,
         pushSpec: PushSpec<GotPushSource, GotPushTarget>,
         initial: Boolean,
-    ): OutgoingResult = OutgoingResult(emptyList(), emptyList())
+    ): OutgoingResult {
+        val workDir = File(repository.root.path)
+        val target = pushSpec.target
+        return try {
+            val remoteHash = commandLine.remoteBranchHash(workDir, target.remoteName, target.branch)
+            val commits = commandLine.outgoingCommitIds(workDir, remoteHash)
+                .map { id -> GotFullCommitDetails(commandLine.catCommit(workDir, id), repository.root) }
+            OutgoingResult(commits, emptyList())
+        } catch (e: VcsException) {
+            OutgoingResult(emptyList(), listOf(VcsError(e.message)))
+        }
+    }
 }
blob - /dev/null
blob + 6109655e214c356044f7d12f5641baa37d9d2164 (mode 644)
--- /dev/null
+++ src/main/kotlin/dev/nezzontli/gotvcs/push/GotFullCommitDetails.kt
@@ -0,0 +1,39 @@
+package dev.nezzontli.gotvcs.push
+
+import com.intellij.openapi.vcs.changes.Change
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.vcs.log.Hash
+import com.intellij.vcs.log.VcsFullCommitDetails
+import com.intellij.vcs.log.VcsUser
+import com.intellij.vcs.log.impl.HashImpl
+import com.intellij.vcs.log.util.VcsUserUtil
+import dev.nezzontli.gotvcs.cli.GotCommitObject
+
+/**
+ * Adapts a [GotCommitObject] (parsed from `got cat <commit-id>`) to the
+ * Push dialog's outgoing-commits list. got has no per-file change list
+ * cheaply available for an arbitrary historical commit here, so
+ * [getChanges] is empty; the dialog only needs id/author/message/parents
+ * to render its commit list.
+ */
+class GotFullCommitDetails(
+    private val commitObject: GotCommitObject,
+    private val root: VirtualFile,
+) : VcsFullCommitDetails {
+
+    private val hash: Hash = HashImpl.build(commitObject.commitId)
+    private val author: VcsUser = VcsUserUtil.createUser(commitObject.authorName, commitObject.authorEmail)
+
+    override fun getId(): Hash = hash
+    override fun getParents(): List<Hash> = commitObject.parents.map { HashImpl.build(it) }
+    override fun getTimestamp(): Long = commitObject.authorTimestamp * 1000L
+    override fun getRoot(): VirtualFile = root
+    override fun getSubject(): String = commitObject.message.lineSequence().firstOrNull().orEmpty()
+    override fun getAuthor(): VcsUser = author
+    override fun getCommitter(): VcsUser = author
+    override fun getAuthorTime(): Long = commitObject.authorTimestamp * 1000L
+    override fun getCommitTime(): Long = commitObject.authorTimestamp * 1000L
+    override fun getFullMessage(): String = commitObject.message
+    override fun getChanges(): Collection<Change> = emptyList()
+    override fun getChanges(parent: Int): Collection<Change> = emptyList()
+}
blob - ae7f6076ead4322cc0a63a24d017ac0f76b98a8e
blob + f893f92824fea712e2695c5cbf3f47507df21b36
--- src/main/kotlin/dev/nezzontli/gotvcs/push/GotPushSupport.kt
+++ src/main/kotlin/dev/nezzontli/gotvcs/push/GotPushSupport.kt
@@ -15,7 +15,7 @@ class GotPushSupport(private val project: Project) : P
 
     private val commandLine = GotCommandLineWrapper()
     private val pusher = GotPusher(commandLine)
-    private val outgoingCommitsProvider = GotOutgoingCommitsProvider()
+    private val outgoingCommitsProvider = GotOutgoingCommitsProvider(commandLine)
 
     override fun getVcs(): AbstractVcs = ProjectLevelVcsManager.getInstance(project).findVcsByName(GotVcs.NAME)!!
 
blob - ba31c5d498654310fa732f8323f263e9daa97f3b
blob + 602288db61a733ba007e8129fde8ca2afbe9f7ce
--- src/main/kotlin/dev/nezzontli/gotvcs/push/GotPushTarget.kt
+++ src/main/kotlin/dev/nezzontli/gotvcs/push/GotPushTarget.kt
@@ -7,7 +7,7 @@ import com.intellij.dvcs.push.PushTarget
  * be a no-op, so [hasSomethingToPush] always returns true: `got send` itself
  * reports "nothing to send" if that turns out to be the case.
  */
-class GotPushTarget(private val remoteName: String, private val branch: String) : PushTarget {
+class GotPushTarget(val remoteName: String, val branch: String) : PushTarget {
     override fun hasSomethingToPush(): Boolean = true
     override fun getPresentation(): String = "$remoteName/$branch"
 }