7 Commits

Author SHA1 Message Date
dbc1bdd105 Branch support (#7)
All checks were successful
Test Workflow / Lint and test library (push) Successful in 1m51s
Publish Workflow / Publish library (push) Successful in 2m18s
Closes #6.

Implements branch support by _only extending_ the existing functionality. Note that the existing functionality DOES NOT implement a `parent` pointer for commits, and as a result branches are also implemented differently to how git implements them.

git branches are simply a named pointer to a commit object (which then has a pointer to its parent commit), here branches store the entire sequence of pointers to commit objects.

### Why different to git?
I made a mistake in the initial implementation by using a `TreeSet` instead of manually implementing a tree for commits. This allowed the ability to insert commits in-between other commits, which isn't normally possible in `git` _without rebasing which changes commits' hashes_.

The specification for this feature stated:
> All functionality that is implemented already should be preserved.

And so since I cannot remove this functionality any more, I have to build on top of it.

Reviewed-on: #7
2024-01-10 12:21:25 +00:00
7233a7e8d1 Cleanup and privatise commit comparator
All checks were successful
Publish Workflow / Publish library (push) Successful in 8m28s
Test Workflow / Lint and test library (push) Successful in 16m51s
2023-12-05 02:00:35 +00:00
6cb4f82fce Cleanup comments in README and Commit.kt
All checks were successful
Test Workflow / Lint and test library (push) Successful in 16m49s
2023-12-02 20:51:34 +00:00
804778b0fc Improve README - usage (#5)
All checks were successful
Publish Workflow / Publish library (push) Successful in 7m57s
Test Workflow / Lint and test library (push) Successful in 16m38s
Include instructions for usage of library in README, as well as how to view documentation.

Reviewed-on: #5
2023-12-02 03:50:13 +00:00
0e1afa6dc8 Implement toString() for objects (#4)
All checks were successful
Test Workflow / Lint and test library (push) Successful in 17m4s
> Search for specific commit by hash or metadata and print its content.

To satisfy this final requirement, we need to have `toString()` implemented for version manager `Object`s which will allow them to be easily (and nicely) printed if needed (includes tests).

Reviewed-on: #4
2023-12-02 02:50:08 +00:00
14950b2f5f Documentation generation (Dokka HTML) (#3)
All checks were successful
Publish Workflow / Publish library (push) Successful in 8m18s
Test Workflow / Lint and test library (push) Successful in 17m2s
Generate documentation HTML and `javadoc.jar`.

Reviewed-on: #3
2023-12-02 02:05:55 +00:00
6d8c6008e1 Initial publish (#2)
All checks were successful
Publish Workflow / Publish library (push) Successful in 8m4s
Test Workflow / Lint and test library (push) Successful in 18m47s
CI for publishing library to gitea packages on tag.

Reviewed-on: #2
2023-12-02 01:00:03 +00:00
10 changed files with 272 additions and 39 deletions

40
.github/workflows/publish.yaml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Publish Workflow
on:
push:
tags:
- v*
jobs:
publish:
name: Publish library
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v3
with:
distribution: adopt
java-version: 17
- name: Verify Gradle wrapper
uses: gradle/wrapper-validation-action@v1
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Run checks
run: ./gradlew check
- name: Parse parameters
id: parse
run: |
export VERSION="$(echo ${{ github.ref_name }} | cut -c2-)"
echo "Parsed version: '$VERSION'"
echo "tinyvm_version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Publish to Gitea package repository
env:
TINYVM_VERSION: ${{ steps.parse.outputs.tinyvm_version }}
GITEA_USERNAME: ${{ github.repository_owner }}
GITEA_TOKEN: ${{ secrets.deploy_token }}
run: ./gradlew publishAllPublicationsToGiteaRepository

View File

@@ -1,4 +1,4 @@
name: Main Workflow name: Test Workflow
on: on:
pull_request: pull_request:
branches: branches:
@@ -16,7 +16,7 @@ jobs:
- name: Set up Java - name: Set up Java
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:
distribution: 'adopt' distribution: adopt
java-version: 17 java-version: 17
- name: Verify Gradle wrapper - name: Verify Gradle wrapper

View File

@@ -10,6 +10,26 @@ The package is named `tinyvm` for 'tiny version manager'.
Since this is an internship application project, I have assumed that a minimal usage of external libraries is preferred, Since this is an internship application project, I have assumed that a minimal usage of external libraries is preferred,
so that a greater technical understanding can be demonstrated. so that a greater technical understanding can be demonstrated.
## Usage
### Gradle
```kotlin
repositories {
// other repositories
maven { url "https://git.koval.net/api/packages/cyclane/maven" }
}
dependencies {
// other dependencies
implementation("net.koval.teamcity-gitea-test-task:tinyvm:0.1.1")
}
```
### Documentation
Use autocompletion and hover menus in your IDE, or download the
[generated HTML documentation](https://git.koval.net/cyclane/teamcity-gitea-test-task/releases/download/v0.1.1/tinyvm-0.1.1-javadoc.zip)
from the [latest release](https://git.koval.net/cyclane/teamcity-gitea-test-task/releases).
## Instructions ## Instructions
Create a library that implements simple Git functionality. You need to implement at least three entities: Create a library that implements simple Git functionality. You need to implement at least three entities:

View File

@@ -1,10 +1,12 @@
plugins { plugins {
kotlin("jvm") version "1.9.21" kotlin("jvm") version "1.9.21"
id("org.jmailen.kotlinter") version "4.1.0" id("org.jmailen.kotlinter") version "4.1.0"
id("org.jetbrains.dokka") version "1.9.10"
`maven-publish`
} }
group = "net.koval" group = "net.koval.teamcity-gitea-test-task"
version = "1.0-SNAPSHOT" version = System.getenv("TINYVM_VERSION")
repositories { repositories {
mavenCentral() mavenCentral()
@@ -21,3 +23,46 @@ tasks.test {
kotlin { kotlin {
jvmToolchain(17) jvmToolchain(17)
} }
val dokkaHtml by tasks.getting(org.jetbrains.dokka.gradle.DokkaTask::class)
val javadocJar: TaskProvider<Jar> by tasks.registering(Jar::class) {
dependsOn(dokkaHtml)
archiveClassifier.set("javadoc")
from(dokkaHtml.outputDirectory)
}
publishing {
publications.register<MavenPublication>("gpr") {
artifactId = "tinyvm"
from(components["java"])
artifact(javadocJar)
pom {
name.set("TeamCity support for Gitea - Test Task - tiny version manager")
description.set("This is a small project to implement a subset of git's functionality in Kotlin and was " +
"created using the instructions below as part of my application to the JetBrains internship project " +
"\"TeamCity support for Gitea\".")
url.set("https://git.koval.net/cyclane/teamcity-gitea-test-task")
developers {
developer {
id.set("cyclane")
name.set("Gleb Koval")
email.set("gleb@koval.net")
}
}
scm {
url.set("https://git.koval.net/cyclane/teamcity-gitea-test-task")
}
}
}
repositories {
maven {
name = "Gitea"
url = uri("https://git.koval.net/api/packages/cyclane/maven")
credentials {
username = System.getenv("GITEA_USERNAME")
password = System.getenv("GITEA_TOKEN")
}
}
}
}

View File

@@ -1,29 +1,20 @@
package tinyvm package tinyvm
import java.security.MessageDigest
import java.time.Instant import java.time.Instant
abstract class Object( /**
val type: String, * Commits are a pointer to a 'head' tree with some metadata.
) { */
abstract val data: String
fun hash(): String =
MessageDigest
.getInstance("SHA-1")
.digest("$type ${data.length}\u0000$data".toByteArray())
.toHex()
}
class Commit( class Commit(
val tree: Tree, val tree: Tree,
val author: Author, val author: Author,
val message: String, val message: String,
val timestamp: Instant, val timestamp: Instant,
) : Object("commit") { ) : Object("commit") {
// Use \n\n for end of header in-case additional metadata is implemented in the future.
override val data: String override val data: String
get() = "tree ${tree.hash()}\nauthor $author\ntimestamp ${timestamp.epochSecond}\n\n$message" get() = "tree ${tree.hash()}\nauthor $author\ntimestamp ${timestamp.epochSecond}\n\n$message"
override fun toString(): String = "commit ${hash()}\n$data"
} }
data class Author( data class Author(

View File

@@ -1,5 +0,0 @@
package tinyvm
import java.util.HexFormat
fun ByteArray.toHex(): String = HexFormat.of().formatHex(this)

View File

@@ -0,0 +1,17 @@
package tinyvm
import java.security.MessageDigest
import java.util.HexFormat
/**
* Represents an arbitrary version manager object.
*/
abstract class Object(val type: String) {
abstract val data: String
fun hash(): String =
HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-1")
.digest("$type ${data.length}\u0000$data".toByteArray()),
)
}

View File

@@ -1,22 +1,59 @@
package tinyvm package tinyvm
import java.util.TreeSet
class HashCollisionException(hash: String) : Exception("Different object types with identical hash '$hash'") class HashCollisionException(hash: String) : Exception("Different object types with identical hash '$hash'")
class CommitTimeComparator : Comparator<Commit> { private class CommitTimeComparator : Comparator<Commit> {
override fun compare( override fun compare(
o1: Commit, o1: Commit,
o2: Commit, o2: Commit,
): Int = (o1.timestamp.epochSecond - o2.timestamp.epochSecond).toInt() ): Int = (o1.timestamp.epochSecond - o2.timestamp.epochSecond).toInt()
} }
class Repository { private val commitTimeComparator = CommitTimeComparator()
private val commits = sortedSetOf(CommitTimeComparator())
private class Branch(val name: String, val commits: TreeSet<Commit>)
class Repository(initialBranch: String = "master") {
private var head = Branch(initialBranch, sortedSetOf(commitTimeComparator))
private val commits = mutableMapOf(initialBranch to head)
val branch: String
get() = head.name
val branches: Set<String>
get() = commits.keys
// Store all objects in one map like git does. This would simplify the data persistence implementation (if there was // Store all objects in one map like git does. This would simplify the data persistence implementation (if there was
// one) and allows for other objects to be added in the future without modifying the data persistence implementation // one) and allows for other objects to be added in the future without modifying the data persistence implementation
// at all. // at all.
private val objects = mutableMapOf<String, Object>() private val objects = mutableMapOf<String, Object>()
/**
* Create a new branch as a copy of the current branch.
* @param name New branch name.
* @param use Whether to switch to this branch.
*/
fun createBranch(
name: String,
use: Boolean = true,
) {
commits[name] =
Branch(
name,
head.commits.clone() as? TreeSet<Commit> ?: throw Exception("TreeSet.clone() unexpected result"),
)
if (use) useBranch(name)
}
/**
* Use (switch to) a branch.
*/
fun useBranch(name: String) {
head = commits[name] ?: throw BranchNotFoundException(name)
}
/** /**
* (Deep) get or put a commit object into the repository. * (Deep) get or put a commit object into the repository.
* This will also get or put all child trees and blobs. * This will also get or put all child trees and blobs.
@@ -33,7 +70,7 @@ class Repository {
timestamp = commit.timestamp, timestamp = commit.timestamp,
) )
objects[hash] = newCommit objects[hash] = newCommit
commits.add(newCommit) head.commits.add(newCommit)
return newCommit return newCommit
} }
@@ -45,12 +82,12 @@ class Repository {
/** /**
* List all commits. * List all commits.
*/ */
fun listCommits(): List<Commit> = commits.toList() fun listCommits(): List<Commit> = head.commits.toList()
/** /**
* Find commit. * Find commit.
*/ */
fun findCommit(predicate: (Commit) -> Boolean): Commit? = commits.find(predicate) fun findCommit(predicate: (Commit) -> Boolean): Commit? = head.commits.find(predicate)
/** /**
* Dump repository objects * Dump repository objects
@@ -90,3 +127,5 @@ class Repository {
private inline fun <reified T : Object> findObject(hash: String): T? = private inline fun <reified T : Object> findObject(hash: String): T? =
objects[hash]?.let { it as? T ?: throw HashCollisionException(hash) } objects[hash]?.let { it as? T ?: throw HashCollisionException(hash) }
} }
class BranchNotFoundException(name: String) : Exception("Branch '$name' does not exist")

View File

@@ -1,7 +1,13 @@
package tinyvm package tinyvm
/**
* Tree nodes are either trees or blobs, represented by this sealed class.
*/
sealed class Node(type: String) : Object(type) sealed class Node(type: String) : Object(type)
/**
* A tree is a set of named nodes.
*/
class Tree(val nodes: Map<String, Node>) : Node("tree") { class Tree(val nodes: Map<String, Node>) : Node("tree") {
// For simplicity just use the hex-formatted hash, not the actual value like git does. // For simplicity just use the hex-formatted hash, not the actual value like git does.
override val data: String override val data: String
@@ -9,6 +15,22 @@ class Tree(val nodes: Map<String, Node>) : Node("tree") {
nodes.map { (name, node) -> nodes.map { (name, node) ->
"${node.type} $name\u0000${node.hash()}" "${node.type} $name\u0000${node.hash()}"
}.sorted().joinToString() }.sorted().joinToString()
override fun toString(): String =
"tree ${hash()}\n" +
nodes.map { (name, node) ->
when (node) {
is Tree -> "+$name/\n"
is Blob -> "+$name\n"
} + node.toString().insertLeftMargin()
}.sorted().joinToString("\n")
} }
class Blob(override val data: String) : Node("blob") /**
* A blob is a data container.
*/
class Blob(override val data: String) : Node("blob") {
override fun toString(): String = "blob ${hash()}\n${data.insertLeftMargin()}"
}
private fun String.insertLeftMargin(): String = split('\n').joinToString("\n") { "| $it" }

View File

@@ -18,22 +18,22 @@ internal class RepositoryTest {
tree = tree =
Tree( Tree(
mapOf( mapOf(
"dir2" to
Tree(
mapOf(
"test1.txt" to Blob("This is a second file"),
),
),
"dir1" to "dir1" to
Tree( Tree(
mapOf( mapOf(
"test1.txt" to Blob("Hello World!"), "test1.txt" to Blob("Hello World!"),
), ),
), ),
"dir2" to
Tree(
mapOf(
"test2.txt" to Blob("This is a second file"),
),
),
), ),
), ),
author = Author("Gleb Koval", "gleb@koval.net"), author = Author("Gleb Koval", "gleb@koval.net"),
message = "Move test1.txt and add dir2/test2.txt", message = "Move test1.txt to dir1 and add dir2/test1.txt",
timestamp = Instant.ofEpochSecond(50), timestamp = Instant.ofEpochSecond(50),
), ),
Commit( Commit(
@@ -49,7 +49,7 @@ internal class RepositoryTest {
"dir2" to "dir2" to
Tree( Tree(
mapOf( mapOf(
"test2.txt" to Blob("This is a second file"), "test1.txt" to Blob("This is a second file"),
), ),
), ),
"README.md" to Blob("# This is a test repo!"), "README.md" to Blob("# This is a test repo!"),
@@ -117,4 +117,68 @@ internal class RepositoryTest {
assertEquals(committed, repository.findCommit { it.message.matches("Move.*".toRegex()) }) assertEquals(committed, repository.findCommit { it.message.matches("Move.*".toRegex()) })
assertEquals(null, repository.getCommit("00000000000000000000")) assertEquals(null, repository.getCommit("00000000000000000000"))
} }
@Test
fun `can create and use branch`() {
repository.commit(commits[0])
repository.commit(commits[1])
repository.commit(commits[2])
repository.createBranch("test-branch", use = false)
assertEquals("master", repository.branch)
repository.createBranch("test-branch-2")
assertEquals("test-branch-2", repository.branch)
assertEquals(setOf("master", "test-branch", "test-branch-2"), repository.branches)
assertEquals(commits.map { it.hash() }, repository.listCommits().map { it.hash() })
assertEquals(10, repository.dumpObjects().size)
}
@Test
fun `can commit to branch`() {
repository.commit(commits[0])
repository.createBranch("test-branch")
repository.commit(commits[1])
repository.createBranch("test-branch-2")
repository.commit(commits[2])
assertEquals(commits.map { it.hash() }, repository.listCommits().map { it.hash() })
repository.useBranch("test-branch")
assertEquals(commits.take(2).map { it.hash() }, repository.listCommits().map { it.hash() })
repository.useBranch("master")
assertEquals(commits.take(1).map { it.hash() }, repository.listCommits().map { it.hash() })
}
@Test
fun `can display commit`() {
assertEquals(
"""
commit 804c6f0c66da0ea0eab8ac29f23a627e03a962b2
tree aaebbb258bce30749fc302cbd161b78462252a32
author Gleb Koval <gleb@koval.net>
timestamp 0
Add test1.txt
""".trimIndent(),
commits[0].toString(),
)
}
@Test
fun `can display tree`() {
assertEquals(
"""
tree 048b8e267dd7a6c5d4849d59f6ff82e6ae948f13
+dir1/
| tree aaebbb258bce30749fc302cbd161b78462252a32
| +test1.txt
| | blob c57eff55ebc0c54973903af5f72bac72762cf4f4
| | | Hello World!
+dir2/
| tree 0e14cbdba02924cd63a58c2492fc3dd05dc682bd
| +test1.txt
| | blob da3fcc31cbc16fcbb7526d68771f77e7e7f02fb1
| | | This is a second file
""".trimIndent(),
commits[1].tree.toString(),
)
}
} }