com.sageserpent.kineticmerge.Main.scala Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of kinetic-merge_3 Show documentation
Show all versions of kinetic-merge_3 Show documentation
Merge branches in the presence of code motion within and between files.
The newest version!
package com.sageserpent.kineticmerge
import cats.data.{EitherT, WriterT}
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import cats.syntax.foldable.toFoldableOps
import cats.syntax.traverse.toTraverseOps
import cats.{Eq, Order}
import com.google.common.hash.{Funnel, HashFunction, Hashing}
import com.sageserpent.kineticmerge.Main.MergeInput.*
import com.sageserpent.kineticmerge.core.*
import com.sageserpent.kineticmerge.core.CodeMotionAnalysis.Configuration
import com.sageserpent.kineticmerge.core.CodeMotionAnalysisExtension.*
import com.sageserpent.kineticmerge.core.Token.tokens
import com.softwaremill.tagging.*
import com.typesafe.scalalogging.StrictLogging
import fansi.Str
import os.{CommandResult, Path, RelPath}
import scopt.{DefaultOEffectSetup, OParser}
import scala.annotation.varargs
import scala.collection.BuildFrom
import scala.io.Source
import scala.util.Try
object Main extends StrictLogging:
// NOTE: the use of Git below is based on spike work on MacOS - the version of
// Git shipped tends to be a *long* way behind the latest release, so the
// latest and greatest versions of commands are not always available. At time
// of writing, Mac OS Ventura 13.5.2 ships Git 2.24.3, contrast with Git
// 2.42.0 being the latest stable release.
private type ErrorOrOperationMessage =
Either[String @@ Tags.ErrorMessage, String]
private type WorkflowLog = List[ErrorOrOperationMessage]
private type WorkflowLogWriter[Payload] = WriterT[IO, WorkflowLog, Payload]
private type Workflow[Payload] =
EitherT[WorkflowLogWriter, String @@ Tags.ErrorMessage, Payload]
private val whitespaceRun = "\\s+"
private val noBranchProvided: String @@ Tags.CommitOrBranchName =
"".taggedWith[Tags.CommitOrBranchName]
private val fakeModeForDeletion: String @@ Tags.Mode =
"0".taggedWith[Tags.Mode]
private val fakeBlobIdForDeletion: String @@ Tags.BlobId =
"0000000000000000000000000000000000000000".taggedWith[Tags.BlobId]
private val successfulMerge: Int @@ Tags.ExitCode =
0.taggedWith[Tags.ExitCode]
private val conflictedMerge: Int @@ Tags.ExitCode =
1.taggedWith[Tags.ExitCode]
private val incorrectCommandLine: Int @@ Tags.ExitCode =
2.taggedWith[Tags.ExitCode]
private val error: Int @@ Tags.ExitCode = 3.taggedWith[Tags.ExitCode]
private val bestCommonAncestorStageIndex: Int @@ Tags.StageIndex =
1.taggedWith[Tags.StageIndex]
private val ourStageIndex: Int @@ Tags.StageIndex =
2.taggedWith[Tags.StageIndex]
private val theirStageIndex: Int @@ Tags.StageIndex =
3.taggedWith[Tags.StageIndex]
// NOTE: allow a degree of overlap between the alternate groups, this avoids
// doing any downstream disambiguation between a percentage and either an
// implied or an explicit fraction in the range [0, 1].
private val matchThresholdRegex = raw"(\d{1,3})%|(\d+)|([01]\.\d+)".r.anchored
/** Entry point for the application. In contrast with [[apply]], it will pass
* an exit code via [[System.exit]] and thus force a shutdown of the
* application.
* @param commandLineArguments
* Command line arguments as an array, as per Scala's entry point
* specification.
*/
def main(commandLineArguments: Array[String]): Unit =
System.exit(
apply(progressRecording = ConsoleProgressRecording, commandLineArguments*)
)
end main
/** @param commandLineArguments
* Command line arguments as varargs.
* @return
* The exit code as a plain integer, suitable for consumption by both Scala
* and Java client code.
*/
@varargs
def apply(commandLineArguments: String*): Int = apply(
progressRecording = NoProgressRecording,
commandLineArguments = commandLineArguments*
)
/** @param progressRecording
* @param commandLineArguments
* Command line arguments as varargs.
* @return
* The exit code as a plain integer, suitable for consumption by both Scala
* and Java client code.
*/
@varargs
def apply(
progressRecording: ProgressRecording,
commandLineArguments: String*
): Int =
val logbackRootLevelLoggingJavaPropertyName = "logback-root-level"
val parser =
val builder = OParser.builder[ApplicationRequest]
import builder.*
val kineticMergeVersion = Source
.fromResource("version.txt")
.getLines()
.nextOption()
.getOrElse("Not a packaged build.")
OParser.sequence(
programName("kinetic-merge"),
head("kinetic-merge", s"$kineticMergeVersion"),
help(name = "help").text("Output this summary."),
version(name = "version").text("Show the version of this command."),
opt[Unit](name = "no-commit")
.action((noCommit, commandLineArguments) =>
commandLineArguments.copy(noCommit = true)
)
.text(
"Do not commit a successful merge - leave merged changes staged in the index for review."
),
opt[Unit](name = "no-ff")
.action((noFastForward, commandLineArguments) =>
commandLineArguments.copy(noFastForward = true)
)
.text("Prevent fast-forward merge - make a merge commit instead."),
opt[Int](name = "minimum-match-size")
.validate(minimumMatchSize =>
if 0 < minimumMatchSize then success
else failure(s"Minimum match size must be positive.")
)
.action((minimumMatchSize, commandLineArguments) =>
commandLineArguments
.copy(minimumMatchSize = minimumMatchSize)
)
.text("Minimum number of tokens for a match to be considered."),
opt[String](name = "match-threshold")
.validate(matchThreshold =>
if matchThresholdRegex.matches(matchThreshold) then success
else
failure(
s"Match threshold ${underline(matchThreshold)} must be a percentage, or digits to the right of the decimal point of a non-negative fraction less than one, or a fraction at least zero and at most one."
)
)
.action { (matchThreshold, commandLineArguments) =>
val thresholdSizeFractionForMatching = matchThreshold match
case matchThresholdRegex(
percentage,
impliedFraction,
explicitFraction
) =>
(
Option(percentage),
Option(impliedFraction),
Option(explicitFraction)
).match
case (Some(percentage), None, None) =>
// Parse as an integer first.
percentage.toInt.toDouble / 100
case (None, Some(impliedFraction), None) =>
val numberOfDigits = impliedFraction.size
// Push all the digits to the right of the decimal point.
impliedFraction.toInt * Math.pow(10, -numberOfDigits)
case (None, None, Some(explicitFraction)) =>
explicitFraction.toDouble
commandLineArguments.copy(thresholdSizeFractionForMatching =
thresholdSizeFractionForMatching
)
}
.text(
"Minimum fraction of a containing file's size for a section of text to qualify for matching."
),
opt[Int](name = "minimum-ambiguous-match-size")
.validate(minimumAmbiguousMatchSize =>
if 0 <= minimumAmbiguousMatchSize then success
else failure(s"Minimum match size must be zero or positive.")
)
.action((minimumAmbiguousMatchSize, commandLineArguments) =>
commandLineArguments
.copy(minimumAmbiguousMatchSize = minimumAmbiguousMatchSize)
)
.text(
"Minimum number of tokens for an ambiguous match to be considered."
),
arg[String](name = "")
.action((theirBranch, commandLineArguments) =>
commandLineArguments.copy(theirBranchHead =
theirBranch.taggedWith[Tags.CommitOrBranchName]
)
)
.required()
.maxOccurs(1),
checkConfig(commandLineArguments =>
if 0 > commandLineArguments.thresholdSizeFractionForMatching || 1 < commandLineArguments.thresholdSizeFractionForMatching
then
failure(
s"Match threshold fraction ${underline(commandLineArguments.thresholdSizeFractionForMatching)} should be at least zero and at most one."
)
else success
),
note(
"Utility to merge another Git branch's changes ('their branch') into the active Git branch in the current working directory ('our branch')."
),
note(
s"Exits with code $successfulMerge on completed successful merge."
),
note(
s"Exits with code $conflictedMerge on completed merge with conflicts."
),
note(
s"Exits with code $incorrectCommandLine if command line is incorrect."
),
note(
s"Exits with code $error if Git porcelain or the filesystem experiences an error; any changes are rolled back."
),
note(
s"Logging is via Logback and is disabled by default - set the root logging level via the Java system property: ${underline(logbackRootLevelLoggingJavaPropertyName)}."
)
)
end parser
case class EarlyTermination(exitCode: Int @@ Tags.ExitCode)
extends RuntimeException
val applicationRequest: Try[Option[ApplicationRequest]] = Try {
OParser
.parse(
parser,
commandLineArguments,
ApplicationRequest(),
new DefaultOEffectSetup:
// Don't terminate the application, let execution return back to the
// caller via a glorified long-jump.
override def terminate(exitState: Either[String, Unit]): Unit =
throw EarlyTermination(exitState match
case Left(_) => 1.taggedWith[Tags.ExitCode]
case Right(_) => 0.taggedWith[Tags.ExitCode]
)
)
}
applicationRequest.fold(
{
case EarlyTermination(exitCode) => exitCode
case exception: Exception =>
exception.printStackTrace()
error
},
_.fold(ifEmpty = incorrectCommandLine)(
mergeTheirBranch(_)(workingDirectory = os.pwd, progressRecording)
)
)
end apply
def mergeTheirBranch(applicationRequest: ApplicationRequest)(
workingDirectory: Path,
progressRecording: ProgressRecording = NoProgressRecording
): Int @@ Main.Tags.ExitCode =
import applicationRequest.*
val configuration = Configuration(
minimumMatchSize,
thresholdSizeFractionForMatching,
minimumAmbiguousMatchSize,
propagateExceptions = false,
progressRecording = progressRecording
)
val workflow = for
_ <- IO {
os.proc("git", "--version").call(workingDirectory)
}
.labelExceptionWith(errorMessage = "Git is not available.")
topLevel <- IO {
os.proc("git", "rev-parse", "--show-toplevel")
.call(workingDirectory)
.out
.text()
.strip()
}.labelExceptionWith(errorMessage =
"The current working directory is not part of a Git working tree."
)
topLevelWorkingDirectory <- IO { Path(topLevel) }
.labelExceptionWith(errorMessage =
s"Unexpected error: top level of Git repository ${underline(topLevel)} is not a valid path."
)
inTopLevelWorkingDirectory = InWorkingDirectory(topLevelWorkingDirectory)
ourBranchHead <- inTopLevelWorkingDirectory.ourBranchHead()
oursAlreadyContainsTheirs <- inTopLevelWorkingDirectory
.firstBranchIsContainedBySecond(
theirBranchHead,
ourBranchHead
)
theirsAlreadyContainsOurs <- inTopLevelWorkingDirectory
.firstBranchIsContainedBySecond(
ourBranchHead,
theirBranchHead
)
exitCode <-
if oursAlreadyContainsTheirs
then
// Nothing to do, our branch has all their commits already.
right(successfulMerge)
.logOperation(
s"Nothing to do - our branch ${underline(ourBranchHead)} already contains ${underline(theirBranchHead)}."
)
else if theirsAlreadyContainsOurs && !noFastForward
then
inTopLevelWorkingDirectory.fastForwardToTheirs(
ourBranchHead,
theirBranchHead
)
else // Perform a real merge...
for
_ <- inTopLevelWorkingDirectory.confirmThereAreNoUncommittedChanges(
ourBranchHead
)
bestAncestorCommitId <- inTopLevelWorkingDirectory
.bestAncestorCommitId(ourBranchHead, theirBranchHead)
ourChanges <- inTopLevelWorkingDirectory.changes(
inTopLevelWorkingDirectory,
ourBranchHead,
bestAncestorCommitId,
possessive = "our"
)
theirChanges <- inTopLevelWorkingDirectory.changes(
inTopLevelWorkingDirectory,
theirBranchHead,
bestAncestorCommitId,
possessive = "their"
)
mergeInputs <- inTopLevelWorkingDirectory.mergeInputsOf(
bestAncestorCommitId,
ourBranchHead,
theirBranchHead
)(ourChanges, theirChanges)
exitCode <-
inTopLevelWorkingDirectory.mergeWithRollback(
ourBranchHead,
theirBranchHead,
bestAncestorCommitId,
mergeInputs,
noCommit,
noFastForward,
configuration
)
yield exitCode
end for
yield exitCode
val (log, exitCode) = workflow
.foldF(
errorMessage =>
for _ <- WriterT.tell(List(Left(errorMessage)))
yield error,
WriterT.value
)
.run
.unsafeRunSync()
log.foreach {
case Left(errorMessage) => Console.err.println(errorMessage)
case Right(operationMessage) => Console.println(operationMessage)
}
exitCode
end mergeTheirBranch
extension [Payload](fallible: IO[Payload])
private def labelExceptionWith(errorMessage: String): Workflow[Payload] =
EitherT
.liftAttemptK[WorkflowLogWriter, Throwable]
.apply(WriterT.liftF(fallible))
.leftMap(exception =>
// TODO: something pure, functional and wholesome that could be seen
// at high church...
logger.error(exception.getMessage)
errorMessage.taggedWith[Tags.ErrorMessage]
)
end extension
extension [Payload](workflow: Workflow[Payload])
private def logOperation(message: String): Workflow[Payload] =
workflow.semiflatTap(_ => WriterT.tell(List(Right(message))))
end extension
private def right[Payload](payload: Payload): Workflow[Payload] =
EitherT.rightT[WorkflowLogWriter, String @@ Tags.ErrorMessage](payload)
private def underline(anything: Any): Str =
fansi.Underlined.On(anything.toString)
private def left[Payload](errorMessage: String): Workflow[Payload] =
EitherT.leftT[WorkflowLogWriter, Payload](
errorMessage.taggedWith[Tags.ErrorMessage]
)
private def temporaryFile(
suffix: String,
content: String @@ Tags.Content
): Workflow[Path] =
for temporaryFile <- IO {
os.temp(
contents = content,
prefix = "kinetic-merge-",
suffix = ".base",
deleteOnExit = true
)
}.labelExceptionWith(
s"Unexpected error: could not create temporary file."
)
yield temporaryFile
case class ApplicationRequest(
theirBranchHead: String @@ Main.Tags.CommitOrBranchName =
noBranchProvided,
noCommit: Boolean = false,
noFastForward: Boolean = false,
minimumMatchSize: Int =
// Don't allow monograph matches - these would bombard the merge with
// useless all-sides matches that create a *lot* of overhead. In
// practice, avoiding small window sizes above one leads to a much
// better merge as well.
4,
thresholdSizeFractionForMatching: Double = 0,
minimumAmbiguousMatchSize: Int = 10
)
enum Change:
case Modification(
mode: String @@ Tags.Mode,
blobId: String @@ Tags.BlobId,
content: String @@ Tags.Content
)
case Addition(
mode: String @@ Tags.Mode,
blobId: String @@ Tags.BlobId,
content: String @@ Tags.Content
)
case Deletion
end Change
enum MergeInput:
case JustOurModification(
ourModification: Change.Modification,
bestAncestorCommitIdContent: String @@ Tags.Content
)
case JustTheirModification(
theirModification: Change.Modification,
bestAncestorCommitIdContent: String @@ Tags.Content
)
case JustOurAddition(ourAddition: Change.Addition)
case JustTheirAddition(theirAddition: Change.Addition)
case JustOurDeletion(bestAncestorCommitIdContent: String @@ Tags.Content)
case JustTheirDeletion(bestAncestorCommitIdContent: String @@ Tags.Content)
case OurModificationAndTheirDeletion(
ourModification: Change.Modification,
bestAncestorCommitIdMode: String @@ Tags.Mode,
bestAncestorCommitIdBlobId: String @@ Tags.BlobId,
bestAncestorCommitIdContent: String @@ Tags.Content
)
case TheirModificationAndOurDeletion(
theirModification: Change.Modification,
bestAncestorCommitIdMode: String @@ Tags.Mode,
bestAncestorCommitIdBlobId: String @@ Tags.BlobId,
bestAncestorCommitIdContent: String @@ Tags.Content
)
case BothContributeAnAddition(
ourAddition: Change.Addition,
theirAddition: Change.Addition,
mergedFileMode: String @@ Tags.Mode
)
case BothContributeAModification(
ourModification: Change.Modification,
theirModification: Change.Modification,
bestAncestorCommitIdMode: String @@ Tags.Mode,
bestAncestorCommitIdBlobId: String @@ Tags.BlobId,
bestAncestorCommitIdContent: String @@ Tags.Content,
mergedFileMode: String @@ Tags.Mode
)
case BothContributeADeletion(
bestAncestorCommitIdContent: String @@ Tags.Content
)
end MergeInput
private case class InWorkingDirectory(
workingDirectory: Path
):
def ourBranchHead(): Workflow[String @@ Main.Tags.CommitOrBranchName] =
IO {
val branchName = os
.proc("git", "branch", "--show-current")
.call(workingDirectory)
.out
.text()
.strip()
.taggedWith[Tags.CommitOrBranchName]
if branchName.nonEmpty then branchName
else
// Handle a detached commit.
os.proc("git", "rev-parse", "HEAD")
.call(workingDirectory)
.out
.text()
.strip()
.taggedWith[Tags.CommitOrBranchName]
end if
}.labelExceptionWith(errorMessage =
s"Could not determine a branch name or commit id for our branch head."
)
def firstBranchIsContainedBySecond(
firstBranchHead: String @@ Tags.CommitOrBranchName,
secondBranchHead: String @@ Tags.CommitOrBranchName
): Workflow[Boolean] =
IO {
os.proc(
"git",
"merge-base",
"--is-ancestor",
firstBranchHead,
secondBranchHead
).call(workingDirectory, check = false)
.exitCode
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not determine whether branch ${underline(firstBranchHead)} is an ancestor of branch ${underline(secondBranchHead)}."
).map(0 == _)
def fastForwardToTheirs(
ourBranchHead: String @@ Main.Tags.CommitOrBranchName,
theirBranchHead: String @@ Main.Tags.CommitOrBranchName
): Workflow[Int @@ Main.Tags.ExitCode] =
IO {
os.proc("git", "reset", "--hard", theirBranchHead)
.call(workingDirectory): Unit
successfulMerge
}
.labelExceptionWith(errorMessage =
s"Unexpected error: could not fast-forward our branch ${underline(ourBranchHead)} to their branch ${underline(theirBranchHead)}."
)
.logOperation(
s"Fast forward our branch ${underline(ourBranchHead)} to their branch ${underline(theirBranchHead)}."
)
def confirmThereAreNoUncommittedChanges(
ourBranchHead: String @@ Main.Tags.CommitOrBranchName
): Workflow[Unit] =
IO {
val _ = os
.proc("git", "diff-index", "--exit-code", ourBranchHead)
.call(workingDirectory)
}
.labelExceptionWith(errorMessage =
"There are uncommitted changes prior to commencing the merge."
)
def bestAncestorCommitId(
ourBranchHead: String @@ Main.Tags.CommitOrBranchName,
theirBranchHead: String @@ Main.Tags.CommitOrBranchName
): Workflow[String @@ Main.Tags.CommitOrBranchName] =
IO {
os.proc("git", "merge-base", ourBranchHead, theirBranchHead)
.call(workingDirectory)
.out
.text()
.strip()
.taggedWith[Tags.CommitOrBranchName]
}.labelExceptionWith(errorMessage =
s"Could not determine a best ancestor commit between our branch ${underline(ourBranchHead)} and their branch ${underline(theirBranchHead)}."
)
def changes(
inTopLevelWorkingDirectory: InWorkingDirectory,
branchOrCommit: String @@ Main.Tags.CommitOrBranchName,
bestAncestorCommitId: String @@ Main.Tags.CommitOrBranchName,
possessive: String
): Workflow[Map[Path, Change]] =
IO {
os.proc(
"git",
"diff",
"--no-renames",
"--name-status",
bestAncestorCommitId,
branchOrCommit
).call(workingDirectory)
.out
.lines()
}.labelExceptionWith(errorMessage =
s"Could not determine changes made on $possessive branch ${underline(branchOrCommit)} since ancestor commit ${underline(bestAncestorCommitId)}."
).flatMap(
_.traverse(
inTopLevelWorkingDirectory.pathChangeFor(branchOrCommit)
)
).map(_.toMap)
def pathChangeFor(commitIdOrBranchName: String @@ Tags.CommitOrBranchName)(
line: String
): Workflow[(Path, Change)] =
IO {
line.split(whitespaceRun) match
case Array("M", changedFile) =>
val path = workingDirectory / RelPath(changedFile)
path -> blobAndContentFor(commitIdOrBranchName)(
path
).map(
Change.Modification.apply.tupled
)
case Array("A", addedFile) =>
val path = workingDirectory / RelPath(addedFile)
path -> blobAndContentFor(commitIdOrBranchName)(
path
).map(
Change.Addition.apply.tupled
)
case Array("D", deletedFile) =>
val path = RelPath(deletedFile)
workingDirectory / path -> right(Change.Deletion)
end match
}.labelExceptionWith(errorMessage =
s"Unexpected error - can't parse changes reported by Git ${underline(line)}."
).flatMap { case (path, changed) => changed.map(path -> _) }
private def blobAndContentFor(
commitIdOrBranchName: String @@ Tags.CommitOrBranchName
)(
path: Path
): Workflow[
(String @@ Tags.Mode, String @@ Tags.BlobId, String @@ Tags.Content)
] =
IO {
val line = os
.proc("git", "ls-tree", commitIdOrBranchName, path)
.call(workingDirectory)
.out
.text()
line.split(whitespaceRun) match
case Array(mode, _, blobId, _) =>
val content = os
.proc("git", "cat-file", "blob", blobId)
.call(workingDirectory)
.out
.text()
(
mode.taggedWith[Tags.Mode],
blobId.taggedWith[Tags.BlobId],
content.taggedWith[Tags.Content]
)
end match
}.labelExceptionWith(errorMessage =
s"Unexpected error - can't determine blob id for path ${underline(path)} in commit or branch ${underline(commitIdOrBranchName)}."
)
end blobAndContentFor
def mergeInputsOf(
bestAncestorCommitId: String @@ Tags.CommitOrBranchName,
ourBranchHead: String @@ Tags.CommitOrBranchName,
theirBranchHead: String @@ Tags.CommitOrBranchName
)(
ourChanges: Map[Path, Change],
theirChanges: Map[Path, Change]
): Workflow[List[(Path, MergeInput)]] =
// TODO: use `ourChanges.mergeByKey(theirChanges)` if
// https://github.com/scala/scala-collection-contrib/issues/239 leads to a
// bug-fix.
val outerJoin = ourChanges.mergeByKey(theirChanges)
outerJoin.toList
.traverse {
case (
path,
(
Some(_: Change.Addition),
Some(Change.Deletion | _: Change.Modification)
)
) =>
left(
s"Unexpected error: file ${underline(path)} has been added on our branch ${underline(ourBranchHead)} and either deleted or modified on their branch ${underline(theirBranchHead)}."
)
case (
path,
(
Some(Change.Deletion | _: Change.Modification),
Some(_: Change.Addition)
)
) =>
left(
s"Unexpected error: file ${underline(path)} has been either deleted or modified on our branch ${underline(ourBranchHead)} and added on their branch ${underline(theirBranchHead)}."
)
case (
path,
(Some(ourModification: Change.Modification), None)
) =>
for (
_,
_,
bestAncestorCommitIdContent
) <- blobAndContentFor(bestAncestorCommitId)(path)
yield path -> JustOurModification(
ourModification,
bestAncestorCommitIdContent
)
case (
path,
(None, Some(theirModification: Change.Modification))
) =>
for (
_,
_,
bestAncestorCommitIdContent
) <- blobAndContentFor(bestAncestorCommitId)(path)
yield path -> JustTheirModification(
theirModification,
bestAncestorCommitIdContent
)
case (
path,
(Some(ourAddition: Change.Addition), None)
) =>
right(path -> JustOurAddition(ourAddition))
case (
path,
(None, Some(theirAddition: Change.Addition))
) =>
right(path -> JustTheirAddition(theirAddition))
case (
path,
(Some(Change.Deletion), None)
) =>
for (
_,
_,
bestAncestorCommitIdContent
) <- blobAndContentFor(bestAncestorCommitId)(path)
yield path -> JustOurDeletion(bestAncestorCommitIdContent)
case (
path,
(None, Some(Change.Deletion))
) =>
for (
_,
_,
bestAncestorCommitIdContent
) <- blobAndContentFor(bestAncestorCommitId)(path)
yield path -> JustTheirDeletion(bestAncestorCommitIdContent)
case (
path,
(
Some(ourModification: Change.Modification),
Some(Change.Deletion)
)
) =>
for (
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId,
bestAncestorCommitIdContent
) <- blobAndContentFor(bestAncestorCommitId)(path)
yield path -> OurModificationAndTheirDeletion(
ourModification,
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId,
bestAncestorCommitIdContent
)
case (
path,
(
Some(Change.Deletion),
Some(theirModification: Change.Modification)
)
) =>
for (
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId,
bestAncestorCommitIdContent
) <- blobAndContentFor(bestAncestorCommitId)(path)
yield path -> TheirModificationAndOurDeletion(
theirModification,
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId,
bestAncestorCommitIdContent
)
case (
path,
(
Some(ourAddition: Change.Addition),
Some(theirAddition: Change.Addition)
)
) =>
for mergedFileMode <-
if ourAddition.mode == theirAddition.mode then
right(ourAddition.mode)
else
left(
s"Conflicting file modes for file ${underline(path)}; on our branch head ${underline(ourAddition.mode)} and on their branch head ${underline(theirAddition.mode)}."
)
yield path -> BothContributeAnAddition(
ourAddition,
theirAddition,
mergedFileMode
)
case (
path,
(
Some(ourModification: Change.Modification),
Some(theirModification: Change.Modification)
)
) =>
for
(
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId,
bestAncestorCommitIdContent
) <- blobAndContentFor(bestAncestorCommitId)(path)
mergedFileMode <-
if bestAncestorCommitIdMode == ourModification.mode then
right(theirModification.mode)
else if bestAncestorCommitIdMode == theirModification.mode then
right(ourModification.mode)
else if ourModification.mode == theirModification.mode then
right(ourModification.mode)
else
left(
s"Conflicting file modes for file ${underline(path)}; on best ancestor commit ${underline(bestAncestorCommitIdMode)}, on our branch head ${underline(ourModification.mode)} and on their branch head ${underline(theirModification.mode)}."
)
yield path -> BothContributeAModification(
ourModification,
theirModification,
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId,
bestAncestorCommitIdContent,
mergedFileMode
)
case (
path,
(
Some(Change.Deletion),
Some(Change.Deletion)
)
) =>
for (
_,
_,
bestAncestorCommitIdContent
) <- blobAndContentFor(bestAncestorCommitId)(path)
yield path -> BothContributeADeletion(bestAncestorCommitIdContent)
}
end mergeInputsOf
def mergeWithRollback(
ourBranchHead: String @@ Main.Tags.CommitOrBranchName,
theirBranchHead: String @@ Main.Tags.CommitOrBranchName,
bestAncestorCommitId: String @@ Main.Tags.CommitOrBranchName,
mergeInputs: List[(Path, MergeInput)],
noCommit: Boolean,
noFastForward: Boolean,
configuration: Configuration
): Workflow[Int @@ Tags.ExitCode] =
val workflow =
for
indexUpdates <- indexUpdates(
bestAncestorCommitId,
ourBranchHead,
theirBranchHead,
configuration
)(mergeInputs)
goodForAMergeCommit = indexUpdates.forall {
case IndexState.OneEntry => true
case IndexState.ConflictingEntries => false
}
exitCodeWhenThereAreNoUnexpectedErrors <-
val commitMessage =
// No underlining here, please...
s"Merge from $theirBranchHead into $ourBranchHead."
if goodForAMergeCommit && !noCommit then
for
treeId <- IO {
os.proc("git", "write-tree")
.call(workingDirectory)
.out
.text()
.strip()
}
.labelExceptionWith(errorMessage =
s"Unexpected error: could not write a tree object from the index."
)
commitId <- IO {
os.proc(
"git",
"commit-tree",
"-p",
ourBranchHead,
"-p",
theirBranchHead,
"-m",
s"'$commitMessage'",
treeId
).call(workingDirectory)
.out
.text()
.strip()
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not create a commit from tree object ${underline(treeId)}"
)
_ <- IO {
os.proc("git", "reset", "--soft", commitId)
.call(workingDirectory)
.out
.text()
}
.labelExceptionWith(errorMessage =
s"Unexpected error: could not advance branch ${underline(ourBranchHead)} to commit ${underline(commitId)}."
)
_ <- right(()).logOperation(
s"Successful merge, made a new commit ${underline(commitId)}"
)
yield successfulMerge
else
for
gitDir <- IO {
os.proc("git", "rev-parse", "--absolute-git-dir")
.call(workingDirectory)
.out
.text()
.strip()
}
.labelExceptionWith(errorMessage =
"Could not determine location of `GIT_DIR`."
)
gitDirPath <- IO {
Path(gitDir)
}
.labelExceptionWith(errorMessage =
s"Unexpected error: `GIT_DIR` reported by Git ${underline(gitDir)} is not a valid path."
)
theirCommitId <- theirCommitId(theirBranchHead)
_ <- IO {
os.write.over(gitDirPath / "MERGE_HEAD", theirCommitId)
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not write `MERGE_HEAD` to reference their branch ${underline(theirBranchHead)}."
)
mergeMode = if noFastForward then "no-ff" else ""
_ <- IO {
os.write.over(gitDirPath / "MERGE_MODE", mergeMode)
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not write `MERGE_MODE` to propagate the merge mode ${underline(mergeMode)}."
)
_ <- IO {
os.write.over(gitDirPath / "MERGE_MSG", commitMessage)
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not write `MERGE_MSG` to prepare the commit message ${underline(commitMessage)}."
)
_ <- right(()).logOperation(
if goodForAMergeCommit then
"Successful merge, leaving merged changes in the index for review..."
else
"Merge conflicts found, handing over for manual resolution..."
)
yield conflictedMerge
end if
yield exitCodeWhenThereAreNoUnexpectedErrors
val workflowWithWorkaround =
for
payload <- workflow
_ <- IO {
// Do this to work around the issue mentioned here:
// https://stackoverflow.com/questions/51146392/cannot-git-merge-abort-until-git-status,
// this has been observed when `git merge-file` successfully writes
// a non-conflicted file after Kinetic Merge has reported a conflict
// for that file.
os.proc("git", "status")
.call(workingDirectory)
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not check the status of the working tree."
)
yield payload
// NASTY HACK: hokey cleanup, need to think about the best approach...
workflowWithWorkaround.leftMap(label =>
try os.proc("git", "reset", "--hard").call(workingDirectory)
catch
case exception =>
println(s"Failed to rollback changes after unexpected error.")
end try
label
)
end mergeWithRollback
def theirCommitId(
theirBranchHead: String @@ Main.Tags.CommitOrBranchName
): Workflow[String @@ Tags.CommitOrBranchName] =
IO {
os.proc("git", "rev-parse", theirBranchHead)
.call(workingDirectory)
.out
.text()
.taggedWith[Tags.CommitOrBranchName]
}.labelExceptionWith(errorMessage =
s"Ref ${underline(theirBranchHead)} is not a valid branch or commit."
)
private def indexUpdates(
bestAncestorCommitId: String @@ Tags.CommitOrBranchName,
ourBranchHead: String @@ Tags.CommitOrBranchName,
theirBranchHead: String @@ Tags.CommitOrBranchName,
configuration: Configuration
)(
mergeInputs: List[(Path, MergeInput)]
): Workflow[List[IndexState]] =
given Eq[Token] = Token.equality
given Order[Token] = Token.comparison
given Funnel[Token] = Token.funnel
given HashFunction = Hashing.murmur3_32_fixed()
for
(baseContentsByPath, leftContentsByPath, rightContentsByPath) <-
mergeInputs.foldM(
(
Map.empty[Path, IndexedSeq[Token]],
Map.empty[Path, IndexedSeq[Token]],
Map.empty[Path, IndexedSeq[Token]]
)
) {
case (
(baseContentsByPath, leftContentsByPath, rightContentsByPath),
(path, mergeInput)
) =>
mergeInput match
case JustOurModification(
ourModification,
bestAncestorCommitIdContent
) =>
val unchangedContent = tokens(bestAncestorCommitIdContent).get
right(
(
baseContentsByPath + (path -> unchangedContent),
leftContentsByPath + (path -> tokens(
ourModification.content
).get),
rightContentsByPath + (path -> unchangedContent)
)
)
case JustTheirModification(
theirModification,
bestAncestorCommitIdContent
) =>
val unchangedContent = tokens(bestAncestorCommitIdContent).get
right(
(
baseContentsByPath + (path -> unchangedContent),
leftContentsByPath + (path -> unchangedContent),
rightContentsByPath + (path -> tokens(
theirModification.content
).get)
)
)
case JustOurAddition(ourAddition) =>
right(
(
baseContentsByPath,
leftContentsByPath + (path -> tokens(
ourAddition.content
).get),
rightContentsByPath
)
)
case JustTheirAddition(theirAddition) =>
right(
(
baseContentsByPath,
leftContentsByPath,
rightContentsByPath + (path -> tokens(
theirAddition.content
).get)
)
)
case JustOurDeletion(bestAncestorCommitIdContent) =>
val unchangedContent = tokens(bestAncestorCommitIdContent).get
right(
(
baseContentsByPath + (path -> unchangedContent),
leftContentsByPath,
rightContentsByPath + (path -> unchangedContent)
)
)
case JustTheirDeletion(bestAncestorCommitIdContent) =>
val unchangedContent = tokens(bestAncestorCommitIdContent).get
right(
(
baseContentsByPath + (path -> unchangedContent),
leftContentsByPath + (path -> unchangedContent),
rightContentsByPath
)
)
case OurModificationAndTheirDeletion(
ourModification,
_,
_,
bestAncestorCommitIdContent
) =>
right(
(
baseContentsByPath + (path -> tokens(
bestAncestorCommitIdContent
).get),
leftContentsByPath + (path -> tokens(
ourModification.content
).get),
rightContentsByPath
)
)
case TheirModificationAndOurDeletion(
theirModification,
_,
_,
bestAncestorCommitIdContent
) =>
right(
(
baseContentsByPath + (path -> tokens(
bestAncestorCommitIdContent
).get),
leftContentsByPath,
rightContentsByPath + (path -> tokens(
theirModification.content
).get)
)
)
case BothContributeAnAddition(
ourAddition,
theirAddition,
_
) =>
right(
(
baseContentsByPath,
leftContentsByPath + (path -> tokens(
ourAddition.content
).get),
rightContentsByPath + (path -> tokens(
theirAddition.content
).get)
)
)
case BothContributeAModification(
ourModification,
theirModification,
_,
_,
bestAncestorCommitIdContent,
_
) =>
right(
(
baseContentsByPath + (path -> tokens(
bestAncestorCommitIdContent
).get),
leftContentsByPath + (path -> tokens(
ourModification.content
).get),
rightContentsByPath + (path -> tokens(
theirModification.content
).get)
)
)
case BothContributeADeletion(bestAncestorCommitIdContent) =>
right(
(
baseContentsByPath + (path -> tokens(
bestAncestorCommitIdContent
).get),
leftContentsByPath,
rightContentsByPath
)
)
}
codeMotionAnalysis: CodeMotionAnalysis[Path, Token] <- EitherT
.fromEither[WorkflowLogWriter] {
CodeMotionAnalysis.of(
base = MappedContentSourcesOfTokens(
baseContentsByPath,
label = s"BASE: ${bestAncestorCommitId.take(8)}"
),
left = MappedContentSourcesOfTokens(
leftContentsByPath,
label = s"OURS: $ourBranchHead"
),
right = MappedContentSourcesOfTokens(
rightContentsByPath,
label = s"THEIRS: $theirBranchHead"
)
)(configuration)
}
.leftMap(_.toString.taggedWith[Tags.ErrorMessage])
(mergeResultsByPath, moveDestinationsReport) = codeMotionAnalysis.merge
_ <- moveDestinationsReport.summarizeInText.foldLeft(right(()))(
_ logOperation _
)
indexStates: List[Option[IndexState]] <-
mergeInputs.traverse { case (path, mergeInput) =>
mergeInput match
case JustOurModification(ourModification, _) =>
val FullyMerged(tokens) = mergeResultsByPath(path): @unchecked
val mergedFileContent = reconstituteTextFrom(tokens)
val ourModificationWasTweakedByTheMerge =
mergedFileContent != ourModification.content
if ourModificationWasTweakedByTheMerge then
for
blobId <- storeBlobFor(path, mergedFileContent)
_ <- restoreFileFromBlobId(
path,
blobId
)
_ <- recordModificationInIndex(
path,
ourModification.mode,
blobId
)
yield Some(IndexState.OneEntry)
else right(None)
end if
case JustTheirModification(theirModification, _) =>
val FullyMerged(tokens) = mergeResultsByPath(path): @unchecked
val mergedFileContent = reconstituteTextFrom(tokens)
val theirModificationWasTweakedByTheMerge =
mergedFileContent != theirModification.content
if theirModificationWasTweakedByTheMerge then
for
blobId <- storeBlobFor(path, mergedFileContent)
_ <- restoreFileFromBlobId(
path,
blobId
)
_ <- recordModificationInIndex(
path,
theirModification.mode,
blobId
)
yield Some(IndexState.OneEntry)
else
for
_ <- restoreFileFromBlobId(
path,
theirModification.blobId
)
_ <- recordModificationInIndex(
path,
theirModification.mode,
theirModification.blobId
)
yield Some(IndexState.OneEntry)
end if
case JustOurAddition(ourAddition) =>
val FullyMerged(tokens) = mergeResultsByPath(path): @unchecked
val mergedFileContent = reconstituteTextFrom(tokens)
val ourAdditionWasTweakedByTheMerge =
mergedFileContent != ourAddition.content
if ourAdditionWasTweakedByTheMerge then
for
blobId <- storeBlobFor(path, mergedFileContent)
_ <- restoreFileFromBlobId(
path,
blobId
)
_ <- recordModificationInIndex(
path,
ourAddition.mode,
blobId
)
yield Some(IndexState.OneEntry)
else right(None)
end if
case JustTheirAddition(theirAddition) =>
val FullyMerged(tokens) = mergeResultsByPath(path): @unchecked
val mergedFileContent = reconstituteTextFrom(tokens)
val theirAdditionWasTweakedByTheMerge =
mergedFileContent != theirAddition.content
if theirAdditionWasTweakedByTheMerge then
for
blobId <- storeBlobFor(path, mergedFileContent)
_ <- restoreFileFromBlobId(
path,
blobId
)
_ <- recordAdditionInIndex(
path,
theirAddition.mode,
blobId
)
yield Some(IndexState.OneEntry)
else
for
_ <- restoreFileFromBlobId(
path,
theirAddition.blobId
)
_ <- recordAdditionInIndex(
path,
theirAddition.mode,
theirAddition.blobId
)
yield Some(IndexState.OneEntry)
end if
case JustOurDeletion(_) =>
right(None)
case JustTheirDeletion(_) =>
for
_ <- recordDeletionInIndex(path)
_ <- deleteFile(path)
yield Some(IndexState.OneEntry)
case OurModificationAndTheirDeletion(
ourModification,
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId,
_
) =>
val tokens = mergeResultsByPath(path) match
case FullyMerged(mergedTokens) => mergedTokens
case MergedWithConflicts(ourMergedTokens, _) =>
// We don't care about their view of the merge - their side
// simply deleted the whole file, so it contributes nothing
// interesting to the merge; the only point of the merge
// here was to pick up propagated edits / deletions and to
// note move destinations.
ourMergedTokens
val mergedFileContent = reconstituteTextFrom(tokens)
val ourModificationWasTweakedByTheMerge =
mergedFileContent != ourModification.content
for
- <- recordDeletionInIndex(path)
- <- recordConflictModificationInIndex(
stageIndex = bestCommonAncestorStageIndex
)(
bestAncestorCommitId,
path,
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId
)
indexState <-
if ourModificationWasTweakedByTheMerge then
if mergedFileContent.nonEmpty then
for
blobId <- storeBlobFor(path, mergedFileContent)
_ <- restoreFileFromBlobId(
path,
blobId
)
_ <- recordConflictModificationInIndex(
stageIndex = ourStageIndex
)(
ourBranchHead,
path,
ourModification.mode,
blobId
).logOperation(
s"Conflict - file ${underline(path)} was modified on our branch ${underline(ourBranchHead)} and deleted on their branch ${underline(theirBranchHead)}."
)
yield IndexState.ConflictingEntries
else
for
_ <- recordDeletionInIndex(path)
_ <- deleteFile(path)
yield IndexState.OneEntry
else
// The modified file would have been present on our
// branch; given that we started with a clean working
// directory tree, we just leave it there to match what
// Git merge does.
for _ <- recordConflictModificationInIndex(
stageIndex = ourStageIndex
)(
ourBranchHead,
path,
ourModification.mode,
ourModification.blobId
).logOperation(
s"Conflict - file ${underline(path)} was modified on our branch ${underline(ourBranchHead)} and deleted on their branch ${underline(theirBranchHead)}."
)
yield IndexState.ConflictingEntries
yield Some(indexState)
end for
case TheirModificationAndOurDeletion(
theirModification,
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId,
_
) =>
val tokens = mergeResultsByPath(path) match
case FullyMerged(mergedTokens) => mergedTokens
case MergedWithConflicts(_, theirMergedTokens) =>
// We don't care about our view of the merge - our side
// simply deleted the whole file, so it contributes nothing
// interesting to the merge; the only point of the merge
// here was to pick up propagated edits / deletions and to
// note move destinations.
theirMergedTokens
val mergedFileContent = reconstituteTextFrom(tokens)
val theirModificationWasTweakedByTheMerge =
mergedFileContent != theirModification.content
for
_ <- recordDeletionInIndex(path)
_ <- recordConflictModificationInIndex(
stageIndex = bestCommonAncestorStageIndex
)(
bestAncestorCommitId,
path,
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId
)
indexState <-
// Git's merge updates the working directory tree with
// *their* modified file which wouldn't have been present on
// our branch prior to the merge. So that's what we do too.
if theirModificationWasTweakedByTheMerge then
if mergedFileContent.nonEmpty then
for
blobId <- storeBlobFor(path, mergedFileContent)
_ <- restoreFileFromBlobId(
path,
blobId
)
_ <- recordConflictModificationInIndex(
stageIndex = theirStageIndex
)(
theirBranchHead,
path,
theirModification.mode,
blobId
).logOperation(
s"Conflict - file ${underline(path)} was deleted on our branch ${underline(ourBranchHead)} and modified on their branch ${underline(theirBranchHead)}."
)
yield IndexState.ConflictingEntries
else
for
_ <- recordDeletionInIndex(path)
_ <- deleteFile(path)
yield IndexState.OneEntry
else
for
_ <- restoreFileFromBlobId(
path,
theirModification.blobId
)
_ <- recordConflictModificationInIndex(
stageIndex = theirStageIndex
)(
theirBranchHead,
path,
theirModification.mode,
theirModification.blobId
).logOperation(
s"Conflict - file ${underline(path)} was deleted on our branch ${underline(ourBranchHead)} and modified on their branch ${underline(theirBranchHead)}."
)
yield IndexState.ConflictingEntries
yield Some(indexState)
end for
case BothContributeAnAddition(_, _, mergedFileMode) =>
mergeResultsByPath(path) match
case FullyMerged(tokens) =>
val mergedFileContent = reconstituteTextFrom(tokens)
for
blobId <- storeBlobFor(path, mergedFileContent)
_ <- restoreFileFromBlobId(
path,
blobId
)
_ <- recordModificationInIndex(
path,
mergedFileMode,
blobId
)
yield Some(IndexState.OneEntry)
end for
case MergedWithConflicts(leftTokens, rightTokens) =>
val leftContent = reconstituteTextFrom(leftTokens)
val rightContent = reconstituteTextFrom(rightTokens)
for
fakeBaseTemporaryFile <- temporaryFile(
suffix = ".base",
content = "".taggedWith[Tags.Content]
)
leftTemporaryFile <- temporaryFile(
suffix = ".left",
content = leftContent
)
rightTemporaryFile <- temporaryFile(
suffix = ".right",
content = rightContent
)
lastMinuteResolution <-
val noPriorContentName = "no prior content"
val exitCode =
os.proc(
"git",
"merge-file",
"-L",
ourBranchHead,
"-L",
s"'$noPriorContentName'",
"-L",
theirBranchHead,
leftTemporaryFile,
fakeBaseTemporaryFile,
rightTemporaryFile
).call(workingDirectory, check = false)
.exitCode
if 0 <= exitCode then right(0 == exitCode)
else
left(
s"Unexpected error: could not generate conflicted file contents on behalf of ${underline(path)} in temporary file ${underline(leftTemporaryFile)}"
)
end if
_ <- IO {
os.copy.over(leftTemporaryFile, path)
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not copy results of conflicted merge in ${underline(leftTemporaryFile)} to working directory tree file ${underline(path)}."
)
leftBlob <- storeBlobFor(path, leftContent)
rightBlob <- storeBlobFor(path, rightContent)
_ <- recordDeletionInIndex(path)
_ <- recordConflictModificationInIndex(
stageIndex = ourStageIndex
)(
ourBranchHead,
path,
mergedFileMode,
leftBlob
)
_ <- recordConflictModificationInIndex(
stageIndex = theirStageIndex
)(
theirBranchHead,
path,
mergedFileMode,
rightBlob
).logOperation(
s"Conflict - file ${underline(path)} was added on our branch ${underline(
ourBranchHead
)} and added on their branch ${underline(theirBranchHead)}${lastMinuteResolutionNotes(lastMinuteResolution)}."
)
yield Some(IndexState.ConflictingEntries)
end for
case BothContributeAModification(
_,
_,
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId,
bestAncestorCommitIdContent,
mergedFileMode
) =>
mergeResultsByPath(path) match
case FullyMerged(tokens) =>
val mergedFileContent = reconstituteTextFrom(tokens)
for
blobId <- storeBlobFor(path, mergedFileContent)
_ <- restoreFileFromBlobId(
path,
blobId
)
_ <- recordModificationInIndex(
path,
mergedFileMode,
blobId
)
yield Some(IndexState.OneEntry)
end for
case MergedWithConflicts(leftTokens, rightTokens) =>
val leftContent = reconstituteTextFrom(leftTokens)
val rightContent = reconstituteTextFrom(rightTokens)
for
baseTemporaryFile <- temporaryFile(
suffix = ".base",
content = bestAncestorCommitIdContent
)
leftTemporaryFile <- temporaryFile(
suffix = ".left",
content = leftContent
)
rightTemporaryFile <- temporaryFile(
suffix = ".right",
content = rightContent
)
lastMinuteResolution <-
val noPriorContentName = "no prior content"
val exitCode =
os.proc(
"git",
"merge-file",
"-L",
ourBranchHead,
"-L",
s"'$noPriorContentName'",
"-L",
theirBranchHead,
leftTemporaryFile,
baseTemporaryFile,
rightTemporaryFile
).call(workingDirectory, check = false)
.exitCode
if 0 <= exitCode then right(0 == exitCode)
else
left(
s"Unexpected error: could not generate conflicted file contents on behalf of ${underline(path)} in temporary file ${underline(leftTemporaryFile)}"
)
end if
_ <- IO {
os.copy.over(leftTemporaryFile, path)
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not copy results of conflicted merge in ${underline(leftTemporaryFile)} to working directory tree file ${underline(path)}."
)
leftBlob <- storeBlobFor(path, leftContent)
rightBlob <- storeBlobFor(path, rightContent)
_ <- recordDeletionInIndex(path)
_ <- recordConflictModificationInIndex(
stageIndex = bestCommonAncestorStageIndex
)(
bestAncestorCommitId,
path,
bestAncestorCommitIdMode,
bestAncestorCommitIdBlobId
)
_ <- recordConflictModificationInIndex(
stageIndex = ourStageIndex
)(
ourBranchHead,
path,
mergedFileMode,
leftBlob
)
_ <- recordConflictModificationInIndex(
stageIndex = theirStageIndex
)(
theirBranchHead,
path,
mergedFileMode,
rightBlob
).logOperation(
s"Conflict - file ${underline(path)} was modified on our branch ${underline(
ourBranchHead
)} and modified on their branch ${underline(theirBranchHead)}${lastMinuteResolutionNotes(lastMinuteResolution)}."
)
yield Some(IndexState.ConflictingEntries)
end for
case BothContributeADeletion(_) =>
// We already have the deletion in our branch, so no need to
// update the index. We do yield a result so that there is still
// a merge commit if this is the only change, though - this
// should *not* be a fast-forward merge.
right(Some(IndexState.OneEntry))
.logOperation(
s"Coincidental deletion of file ${underline(path)} on our branch ${underline(ourBranchHead)} and on their branch ${underline(theirBranchHead)}."
)
}
yield indexStates.flatten
end for
end indexUpdates
private def deleteFile(path: Path): Workflow[Unit] = IO {
os.remove(path): Unit
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not update working directory tree by deleting file ${underline(path)}."
)
private def restoreFileFromBlobId(
path: Path,
blobId: String @@ Main.Tags.BlobId
) =
IO {
os.write.over(
path,
os.proc(
"git",
"cat-file",
"blob",
blobId
).spawn(workingDirectory)
.stdout,
createFolders = true
)
}.labelExceptionWith(errorMessage =
s"Unexpected error: could not update working directory tree with file ${underline(path)}."
)
private def reconstituteTextFrom(
tokens: IndexedSeq[Token]
): String @@ Main.Tags.Content =
tokens.map(_.text).mkString.taggedWith[Tags.Content]
private def recordModificationInIndex(
path: Path,
mode: String @@ Tags.Mode,
blobId: String @@ Tags.BlobId
): Workflow[Unit] =
IO {
val _ = os
.proc("git", "update-index", "--index-info")
.call(
workingDirectory,
stdin = s"$mode $blobId\t${path relativeTo workingDirectory}"
)
}.labelExceptionWith(
s"Unexpected error: could not update index for modified file ${underline(path)}."
)
private def storeBlobFor(
path: Path,
content: String @@ Tags.Content
): Workflow[String @@ Tags.BlobId] =
IO {
val line = os
.proc("git", "hash-object", "-t", "blob", "-w", "--stdin")
.call(workingDirectory, stdin = content)
.out
.text()
line.split(whitespaceRun) match
case Array(blobId) => blobId.taggedWith[Tags.BlobId]
end match
}.labelExceptionWith(errorMessage =
s"Unexpected error - could not create a blob for file ${underline(path)}."
)
private def recordDeletionInIndex(
path: Path
): Workflow[Unit] =
IO {
val _ = os
.proc("git", "update-index", "--index-info")
.call(
workingDirectory,
stdin =
s"$fakeModeForDeletion $fakeBlobIdForDeletion\t${path relativeTo workingDirectory}"
)
}.labelExceptionWith(
s"Unexpected error: could not update index for deleted file ${underline(path)}."
)
private def recordConflictModificationInIndex(
stageIndex: Int @@ Tags.StageIndex
)(
commitIdOrBranchName: String @@ Tags.CommitOrBranchName,
path: Path,
mode: String @@ Tags.Mode,
blobId: String @@ Tags.BlobId
): Workflow[Unit] =
IO {
val _ = os
.proc("git", "update-index", "--index-info")
.call(
workingDirectory,
stdin =
s"$mode $blobId $stageIndex\t${path relativeTo workingDirectory}"
)
}.labelExceptionWith(
s"Unexpected error: could not update conflict stage #${underline(stageIndex)} index for modified file ${underline(path)} from commit or branch ${underline(commitIdOrBranchName)}."
)
end recordConflictModificationInIndex
private def lastMinuteResolutionNotes(lastMinuteResolution: Boolean) =
if lastMinuteResolution then
", but was resolved trivially when creating the conflicted file - leaving it marked as unresolved for now"
else ""
private def recordAdditionInIndex(
path: Path,
mode: String @@ Tags.Mode,
blobId: String @@ Tags.BlobId
): Workflow[Unit] =
IO {
val _ = os
.proc(
"git",
"update-index",
"--add",
"--cacheinfo",
mode,
blobId,
path relativeTo workingDirectory
)
.call(workingDirectory)
}.labelExceptionWith(
s"Unexpected error: could not update index for added file ${underline(path)}."
)
end recordAdditionInIndex
end InWorkingDirectory
private enum IndexState:
case OneEntry
case ConflictingEntries
end IndexState
object Tags:
trait Mode
trait BlobId
trait Content
trait CommitOrBranchName
trait ErrorMessage
trait ExitCode
trait StageIndex
end Tags
end Main
© 2015 - 2025 Weber Informatics LLC | Privacy Policy