Skip to content

Instantly share code, notes, and snippets.

@dmitrii-artuhov
Last active August 10, 2023 23:31
Show Gist options
  • Save dmitrii-artuhov/f7c30137703acb7ca00408be7a3c10e8 to your computer and use it in GitHub Desktop.
Save dmitrii-artuhov/f7c30137703acb7ca00408be7a3c10e8 to your computer and use it in GitHub Desktop.
Minigit - Git version control system replica
package ru.hse.mit.git.components.fs;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import ru.hse.mit.git.GitException;
public class AbstractEditableFile {
protected String filename;
protected Path fullPath;
public String getFilename() {
return filename;
}
protected List<String> loadFileFromDisk() throws GitException {
try (Stream<String> stream = Files.lines(fullPath)) {
return stream.toList();
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
protected void saveFileOnDisk(List<String> lines) throws GitException {
try {
Files.write(fullPath, (Iterable<String>) lines.stream()::iterator);
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
protected void setContentImmediately(byte[] content) throws GitException {
try {
Files.write(fullPath, content);
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
/**
* If file did not exist, then it stores new file in the filesystem
* @param fileBytes
* @throws GitException
*/
protected void save(byte[] fileBytes) throws GitException {
if (!Files.exists(fullPath)) {
try {
Files.createFile(fullPath);
setContentImmediately(fileBytes);
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
}
}
package ru.hse.mit.git.components.fs;
import java.nio.file.Path;
import org.jetbrains.annotations.NotNull;
import ru.hse.mit.git.GitException;
import ru.hse.mit.git.components.utils.MiniGitUtils;
public class BlobFile extends AbstractEditableFile {
private final byte[] fileBytes;
public BlobFile(Path fullPathToDir, byte @NotNull [] fileBytes) {
this.filename = MiniGitUtils.getHashFromBytes(fileBytes);
this.fullPath = Path.of(fullPathToDir.toString(), filename);
this.fileBytes = fileBytes;
}
public void save() throws GitException {
save(fileBytes);
}
}
package ru.hse.mit.git.components.graph;
import java.util.Optional;
public class BlobNode extends Node {
public BlobNode(String nodeName, String hash) {
super(nodeName, NodeType.BLOB_NODE);
this.hash = Optional.of(hash);
}
}
package ru.hse.mit.git.components.fs;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import org.jetbrains.annotations.NotNull;
import ru.hse.mit.git.GitException;
import ru.hse.mit.git.components.utils.MiniGitUtils;
public class CommitFile extends AbstractEditableFile {
private final String author;
private final OffsetDateTime date;
private final String message;
private final String rootNodeHash;
private final String parentCommitHash;
public CommitFile(Path fullPath, @NotNull String rootNodeHash, @NotNull String parentCommitHash, @NotNull String author, @NotNull OffsetDateTime date, @NotNull String message) {
String content = getCommitFileContent(rootNodeHash, parentCommitHash, author, date, message);
this.filename = MiniGitUtils.getHashFromBytes(content.getBytes());
this.fullPath = Path.of(fullPath.toString(), filename);
this.rootNodeHash = rootNodeHash;
this.parentCommitHash = parentCommitHash;
this.date = date;
this.message = message;
this.author = author;
}
public CommitFile(String hash, Path fullPath, @NotNull String rootNodeHash, @NotNull String parentCommitHash, @NotNull String author, @NotNull OffsetDateTime date, @NotNull String message) {
this.filename = hash;
this.fullPath = Path.of(fullPath.toString(), filename);
this.rootNodeHash = rootNodeHash;
this.parentCommitHash = parentCommitHash;
this.date = date;
this.message = message;
this.author = author;
}
public String getParentCommitHash() {
return parentCommitHash;
}
public String getRootNodeHash() {
return rootNodeHash;
}
public static CommitFile load(Path fullPath, String hash) throws GitException {
try {
List<String> lines = Files.readAllLines(Path.of(fullPath.toString(), hash));
// root tree hash
String rootNodeHash = lines.get(0).split(" ")[1];
// parent commit hash
String parentCommitHash = "";
String[] parentCommitHashLine = lines.get(1).split(" ");
if (parentCommitHashLine.length > 1) {
parentCommitHash = parentCommitHashLine[1];
}
// author
String author = lines.get(2).split(" ")[1];
// date
DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
OffsetDateTime date = OffsetDateTime.parse(lines.get(3).split(" ")[1], formatter);
// message
String message = lines.get(4).replaceFirst("message ", "");
return new CommitFile(hash, fullPath, rootNodeHash, parentCommitHash, author, date, message);
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
public void save() throws GitException {
save(getCommitFileContent(rootNodeHash, parentCommitHash, author, date, message).getBytes());
}
public String getInfo() {
return "Commit " + filename + System.lineSeparator()
+ "Author: " + author + System.lineSeparator()
+ "Date: " + date.toString() + System.lineSeparator()
+ System.lineSeparator() + message + System.lineSeparator();
}
private String getCommitFileContent(String rootNodeHash, String parentCommitHash, String author, OffsetDateTime date, String message) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX");
String formattedDate = date.format(formatter);
return
"tree " + rootNodeHash + System.lineSeparator() +
"parent " + parentCommitHash + System.lineSeparator() +
"author " + author + System.lineSeparator() +
"date " + formattedDate + System.lineSeparator() +
"message " + message;
}
}
package ru.hse.mit.git;
import java.io.PrintStream;
import java.util.List;
import org.jetbrains.annotations.NotNull;
public class GitCliImpl implements GitCli {
private PrintStream outputStream = System.out;
private final MiniGit git;
public GitCliImpl(String workingDir) {
git = new MiniGit(workingDir);
}
@Override
public void runCommand(@NotNull String command, @NotNull List<@NotNull String> arguments)
throws GitException {
String gitOutput = "";
switch (command) {
case GitConstants.INIT -> gitOutput = git.init();
case GitConstants.ADD -> gitOutput = git.add(arguments);
case GitConstants.RM -> gitOutput = git.rm(arguments);
case GitConstants.STATUS -> gitOutput = git.status();
case GitConstants.COMMIT -> {
checkExactArguments(command, arguments, 1, List.of("message"));
gitOutput = git.commit(arguments.get(0));
}
case GitConstants.RESET -> {
checkExactArguments(command, arguments, 1,
List.of("to_revision: HEAD~N | branch name | commit hash"));
String toRevision = arguments.get(0);
if (toRevision.startsWith("HEAD~")) {
checkHeadShiftArgumentCorrectness(command, toRevision);
gitOutput = git.reset(getHeadShiftArgumentValue(toRevision));
} else {
gitOutput = git.reset(toRevision);
}
}
case GitConstants.LOG -> {
if (arguments.isEmpty()) {
gitOutput = git.log();
} else {
checkExactArguments(command, arguments, 1,
List.of("from_revision: HEAD~N | branch name | commit hash"));
String fromRevision = arguments.get(0);
if (fromRevision.startsWith("HEAD~")) {
checkHeadShiftArgumentCorrectness(command, fromRevision);
gitOutput = git.log(getHeadShiftArgumentValue(fromRevision));
} else {
gitOutput = git.log(fromRevision);
}
}
}
case GitConstants.CHECKOUT -> {
if (arguments.size() > 1) {
String firstArgument = arguments.get(0);
if (!firstArgument.equals("--")) {
throw new GitException("Command '" + command
+ "' with multiple arguments expects filenames enumeration starting with '--'");
}
gitOutput = git.checkout(arguments.subList(1, arguments.size()));
} else {
checkExactArguments(command, arguments, 1,
List.of("revision: HEAD~N | branch name | commit hash"));
String fromRevision = arguments.get(0);
if (fromRevision.startsWith("HEAD~")) {
checkHeadShiftArgumentCorrectness(command, fromRevision);
gitOutput = git.checkout(getHeadShiftArgumentValue(fromRevision));
} else {
gitOutput = git.checkout(fromRevision);
}
}
}
case GitConstants.BRANCH_CREATE -> {
checkExactArguments(command, arguments, 1, List.of("branch"));
String branchName = arguments.get(0);
gitOutput = git.createBranch(branchName);
}
case GitConstants.SHOW_BRANCHES -> {
gitOutput = git.showBranches();
}
case GitConstants.BRANCH_REMOVE -> {
checkExactArguments(command, arguments, 1, List.of("branch"));
String branchName = arguments.get(0);
gitOutput = git.removeBranch(branchName);
}
case GitConstants.MERGE -> {
checkExactArguments(command, arguments, 1, List.of("branch"));
String branchName = arguments.get(0);
gitOutput = git.merge(branchName);
}
default -> throw new GitException("Unknown command: '" + command + "'");
}
outputStream.print(gitOutput);
}
@Override
public void setOutputStream(@NotNull PrintStream outputStream) {
this.outputStream = outputStream;
}
@Override
public @NotNull String getRelativeRevisionFromHead(int n) throws GitException {
return git.getRelativeRevisionFromHead(n);
}
private void checkExactArguments(String command, List<String> args, int requiredArgsCount, List<String> argsDescriptions) throws GitException {
if (args.size() != requiredArgsCount) {
StringBuilder errorMessage = new StringBuilder();
errorMessage
.append("Command '")
.append(command)
.append("' must be followed by exactly ")
.append(requiredArgsCount)
.append(" argument(s): ");
for (String description : argsDescriptions) {
errorMessage.append("[").append(description).append("]");
}
throw new GitException(errorMessage.toString());
}
}
private void checkHeadShiftArgumentCorrectness(String command, String revision) throws GitException {
String shiftNumber = revision.substring(5); // removing "HEAD~" from string
try {
int res = Integer.parseInt(shiftNumber);
if (res < 0) {
throw new Exception();
}
}
catch (Exception e) {
throw new GitException(
"Command '" + command +
"' accepts argument in HEAD~N format with N being non-negative integer, but got: '" +
shiftNumber + "'"
);
}
}
private int getHeadShiftArgumentValue(String revision) {
String shiftNumber = revision.substring(5); // removing "HEAD~" from string
return Integer.parseInt(shiftNumber);
}
}
package ru.hse.mit.git.components.fs;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import ru.hse.mit.git.GitException;
import ru.hse.mit.git.components.graph.TreeNode;
public class HeadFile extends AbstractEditableFile {
private final Path branchesDir;
private final Path commitsDir;
private final Path treesDir;
public HeadFile(String filename, Path fullPath, Path branchesPath, Path commitsPath, Path treesPath) {
this.filename = filename;
this.fullPath = fullPath;
this.branchesDir = branchesPath;
this.commitsDir = commitsPath;
this.treesDir = treesPath;
}
public String getCurrentBranch() throws GitException {
if (isDetached()) {
return getCurrentCommitHash();
}
else {
String line = loadFileFromDisk().get(0);
return line.split(" ")[1];
}
}
public void setCurrentBranch(String branchName) throws GitException {
if (!branchExists(branchName)) {
throw new GitException("Branch '" + branchName + "' does not exist");
}
String content = "ref " + branchName;
setContentImmediately(content.getBytes());
}
public String getCurrentCommitHash() throws GitException {
if (isDetached()) {
try {
return Files.readString(fullPath);
} catch (IOException e) {
throw new GitException(e);
}
}
else {
try {
File branch = getBranchFile();
return Files.readString(branch.toPath());
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
}
public void setCurrentCommitAsDetached(String commitHash) throws GitException {
setContentImmediately(commitHash.getBytes());
}
public void setCurrentCommit(String commitHash) throws GitException {
if (!commitExists(commitHash)) {
throw new GitException("Commit '" + commitHash + "' does not exist");
}
if (isDetached()) {
setContentImmediately(commitHash.getBytes());
}
else {
try {
File branch = getBranchFile();
Files.write(branch.toPath(), commitHash.getBytes());
} catch (IOException e) {
throw new GitException(e);
}
}
}
public String getShiftedCommitHash(int shift) throws GitException {
String currentCommitHash = getCurrentCommitHash();
int n = shift;
while (n > 0) {
if (currentCommitHash.isEmpty()) {
break;
}
CommitFile commit = CommitFile.load(commitsDir, currentCommitHash);
currentCommitHash = commit.getParentCommitHash();
n--;
}
if (currentCommitHash.isEmpty()) {
throw new GitException("No commit found associated with HEAD~" + shift);
}
return currentCommitHash;
}
public TreeNode loadTree() throws GitException {
if (getCurrentCommitHash().isEmpty()) {
return TreeNode.createRoot();
}
CommitFile currentCommit = CommitFile.load(commitsDir, getCurrentCommitHash());
return TreeNode.loadTree(
treesDir,
currentCommit.getRootNodeHash()
);
}
public boolean branchExists(String branchName) {
return Files.exists(Path.of(branchesDir.toString(), branchName));
}
public boolean commitExists(String commitHash) {
return Files.exists(Path.of(commitsDir.toString(), commitHash));
}
public boolean isDetached() throws GitException {
List<String> lines = loadFileFromDisk();
List<String> data = List.of(lines.get(0).split(" "));
if (data.size() == 1) {
return true;
}
return false;
}
private File getBranchFile() throws GitException {
List<String> lines = loadFileFromDisk();
List<String> data = List.of(lines.get(0).split(" "));
if (data.size() != 2) {
throw new GitException("HEAD is not on branch: " + lines.get(0));
}
String branchName = data.get(1);
File branchFile = Path.of(branchesDir.toString(), branchName).toFile();
if (!branchFile.exists()) {
try {
Files.createFile(branchFile.toPath());
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
return branchFile;
}
}
package ru.hse.mit.git.components.fs;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.stream.Stream;
import ru.hse.mit.git.GitException;
import ru.hse.mit.git.components.utils.MiniGitUtils;
public class IndexFile extends AbstractEditableFile {
public enum FileStatus {
MODIFIED,
NEW,
DELETED
}
private final Map<String, String> entries = new HashMap<>();
public IndexFile(String filename, Path fullPath) {
this.filename = filename;
this.fullPath = fullPath;
}
public Set<Entry<String, String>> getEntries() {
return entries.entrySet();
}
public void load() throws GitException {
entries.clear();
List<String> lines = loadFileFromDisk();
for (String line : lines) {
String[] keyVal = line.split(" ");
entries.put(keyVal[0], keyVal[1]);
}
}
public void save() throws GitException {
List<String> lines = entries.entrySet().stream().map(entry -> entry.getKey() + " " + entry.getValue()).toList();
saveFileOnDisk(lines);
}
public void addEntry(String entryName, String entryHash) {
entries.put(entryName, entryHash);
}
public void removeEntry(String entryName) {
entries.remove(entryName);
}
public void setEntries(Map<String, String> newEtries) {
entries.clear();
entries.putAll(newEtries);
}
public void saveTrackedFilesToWorkingDir(Path workingDir, Path blobsDir) throws GitException {
for (var entry : entries.entrySet()) {
String filename = entry.getKey();
String hash = entry.getValue();
try {
Path path = Path.of(workingDir.toString(), filename);
if (!Files.exists(path)) {
Files.createDirectories(path.getParent());
Files.createFile(path);
}
Files.write(path, Files.readAllBytes(Path.of(blobsDir.toString(), hash)));
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
}
public Map<FileStatus, List<String>> getUntrackedFiles(Path workingDir, Path exclude) throws GitException {
Collection<String> indexFiles = entries.keySet();
Collection<String> workingDirFiles = getFilesFromWorkingDirectory(workingDir, exclude);
Map<FileStatus, List<String>> result = Map.of(
FileStatus.MODIFIED, new ArrayList<>(),
FileStatus.NEW, new ArrayList<>(),
FileStatus.DELETED, new ArrayList<>()
);
Set<String> allFiles = new HashSet<>();
allFiles.addAll(indexFiles);
allFiles.addAll(workingDirFiles);
for (String filename : allFiles) {
boolean indexFileContains = indexFiles.contains(filename);
boolean workingDirContains = workingDirFiles.contains(filename);
if (indexFileContains && workingDirContains) {
byte[] workingDirFileBytes = MiniGitUtils.getFileBytes(Path.of(workingDir.toString(), filename));
String workingDirFileHash = MiniGitUtils.getHashFromBytes(workingDirFileBytes);
if (!entries.get(filename).equals(workingDirFileHash)) {
result.get(FileStatus.MODIFIED).add(filename);
}
}
else if (!indexFileContains && workingDirContains) {
result.get(FileStatus.NEW).add(filename);
}
else if (indexFileContains && !workingDirContains) {
result.get(FileStatus.DELETED).add(filename);
}
}
return result;
}
public Map<FileStatus, List<String>> getReadyToCommitFiles(Map<String, String> repoEntries) {
Collection<String> indexFiles = entries.keySet();
Collection<String> repoFiles = repoEntries.keySet();
Map<FileStatus, List<String>> result = Map.of(
FileStatus.MODIFIED, new ArrayList<>(),
FileStatus.NEW, new ArrayList<>(),
FileStatus.DELETED, new ArrayList<>()
);
Collection<String> allFiles = new HashSet<>();
allFiles.addAll(indexFiles);
allFiles.addAll(repoFiles);
for (String filename : allFiles) {
boolean indexContainsFile = indexFiles.contains(filename);
boolean repoContainsFile = repoFiles.contains(filename);
if (indexContainsFile && repoContainsFile) {
String indexHash = entries.get(filename);
String repoHash = repoEntries.get(filename);
if (!indexHash.equals(repoHash)) {
result.get(FileStatus.MODIFIED).add(filename);
}
}
else if (indexContainsFile && !repoContainsFile) {
result.get(FileStatus.NEW).add(filename);
}
else if (!indexContainsFile && repoContainsFile) {
result.get(FileStatus.DELETED).add(filename);
}
}
return result;
}
public Set<String> getFilesFromWorkingDirectory(Path workingDir, Path exclude) throws GitException {
Set<String> result = new HashSet<>();
try (Stream<Path> files = Files.walk(workingDir)) {
files.forEach(path -> {
if (path.toFile().isDirectory() || path.toString().startsWith(exclude.toString())) {
return;
}
String trimmedPath = path.toString().replace(workingDir.toString(), "");
if (trimmedPath.isEmpty()) {
return;
}
trimmedPath = trimmedPath.substring(1).replace("\\", "/");
result.add(trimmedPath);
});
return result;
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
}
package ru.hse.mit.git;
import java.io.File;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.io.FileUtils;
import org.jetbrains.annotations.NotNull;
import ru.hse.mit.git.components.fs.BlobFile;
import ru.hse.mit.git.components.fs.CommitFile;
import ru.hse.mit.git.components.fs.HeadFile;
import ru.hse.mit.git.components.fs.IndexFile;
import ru.hse.mit.git.components.fs.IndexFile.FileStatus;
import ru.hse.mit.git.components.graph.TreeNode;
import ru.hse.mit.git.components.utils.MiniGitUtils;
public class MiniGit {
private final String workingDir;
private boolean isInitialized = false;
private static final String REPOSITORY_DIR = ".mini-git";
private static final String BLOBS_DIR = "blobs";
private static final String TREES_DIR = "trees";
private static final String COMMITS_DIR = "commits";
private static final String BRANCHES_DIR = "branches";
private static final String HEAD_FILE = "HEAD";
private static final String INDEX_FILE = "INDEX";
private static final String MASTER_BRANCH = "master";
private final HeadFile headFile;
private final IndexFile indexFile;
public MiniGit(String workingDir) {
this.workingDir = workingDir;
this.headFile = new HeadFile(
HEAD_FILE,
getFullPathFromRepository(HEAD_FILE),
getFullPathFromRepository(BRANCHES_DIR),
getFullPathFromRepository(COMMITS_DIR),
getFullPathFromRepository(TREES_DIR)
);
this.indexFile = new IndexFile(INDEX_FILE, getFullPathFromRepository(INDEX_FILE));
}
public String init() throws GitException {
try {
// directories
Files.createDirectories(getFullPathFromRepository(BLOBS_DIR));
Files.createDirectories(getFullPathFromRepository(TREES_DIR));
Files.createDirectories(getFullPathFromRepository(COMMITS_DIR));
Files.createDirectories(getFullPathFromRepository(BRANCHES_DIR));
// files
Files.createFile(getFullPathFromRepository(HEAD_FILE));
Files.createFile(getFullPathFromRepository(INDEX_FILE));
// set master branch to head by default
Files.createFile(getFullPathFromRepository(BRANCHES_DIR, MASTER_BRANCH));
headFile.setCurrentBranch(MASTER_BRANCH);
isInitialized = true;
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
return "Project initialized" + System.lineSeparator();
}
public String add(@NotNull List<String> entryNames) throws GitException {
checkInitialized();
indexFile.load();
Map<String, File> pureFiles = getPureFiles(entryNames);
for (Map.Entry<String, File> fileEntry : pureFiles.entrySet()) {
byte[] fileBytes;
try {
fileBytes = FileUtils.readFileToByteArray(fileEntry.getValue());
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
// create blob
BlobFile blob = new BlobFile(getFullPathFromRepository(BLOBS_DIR), fileBytes);
blob.save();
// add entry to index file
indexFile.addEntry(fileEntry.getKey(), /* hash */ blob.getFilename());
}
indexFile.save();
return "Add completed successful" + System.lineSeparator();
}
public String rm(@NotNull List<String> entryNames) throws GitException {
checkInitialized();
indexFile.load();
Map<String, File> pureFiles = getPureFiles(entryNames);
for (Map.Entry<String, File> fileEntry : pureFiles.entrySet()) {
// remove entry from index file
indexFile.removeEntry(fileEntry.getKey());
}
indexFile.save();
return "Rm completed successful" + System.lineSeparator();
}
public String status() throws GitException {
checkInitialized();
if (headFile.isDetached()) {
// actually, my implementation will show correct diff for the detached HEAD as well
// I will stick to the provided tests, though
return "Error while performing status: Head is detached" + System.lineSeparator();
}
indexFile.load();
Map<IndexFile.FileStatus, List<String>> untrackedFiles = indexFile.getUntrackedFiles(
getFullPathFromWorkingDirectory(),
getFullPathFromRepository()
);
Map<IndexFile.FileStatus, List<String>> readyToCommitFiles = indexFile.getReadyToCommitFiles(
headFile.loadTree().getBlobs()
);
StringBuilder content = new StringBuilder();
content.append("Current branch is '").append(headFile.getCurrentBranch()).append("'").append(System.lineSeparator());
boolean untrackedAdded = appendStatus(content, untrackedFiles, "Untracked files:");
boolean readyToCommitAdded = appendStatus(content, readyToCommitFiles, "Ready to commit:");
if (!untrackedAdded && !readyToCommitAdded) {
content.append("Everything up to date").append(System.lineSeparator());
}
return content.toString();
}
public String commit(@NotNull String message) throws GitException {
checkInitialized();
indexFile.load();
TreeNode root = TreeNode.createRoot();
for (var entry : indexFile.getEntries()) {
String path = entry.getKey();
String blobHash = entry.getValue();
List<String> names = List.of(path.split("/"));
root.addChildren(0, names, blobHash);
}
root.buildGraph();
root.saveGraph(getFullPathFromRepository(TREES_DIR));
CommitFile commit = new CommitFile(
getFullPathFromRepository(COMMITS_DIR),
root.getHash().get(),
headFile.getCurrentCommitHash(),
"Dimechik",
OffsetDateTime.now(),
message
);
commit.save();
headFile.setCurrentCommit(commit.getFilename());
return "Files committed" + System.lineSeparator();
}
/**
*
* @param checkpointName either commit hash or branch name (eg. master)
*/
public String reset(@NotNull String checkpointName) throws GitException {
checkInitialized();
return resetImpl(checkpointName);
}
public String reset(int stepsBackwardsFromHead) throws GitException {
checkInitialized();
return resetImpl(headFile.getShiftedCommitHash(stepsBackwardsFromHead));
}
private String resetImpl(String checkpointName) throws GitException {
// Update HEAD file
// branch
if (Files.exists(getFullPathFromRepository(BRANCHES_DIR, checkpointName))) {
headFile.setCurrentBranch(checkpointName);
}
// commit
else if (Files.exists(getFullPathFromRepository(COMMITS_DIR, checkpointName))) {
headFile.setCurrentCommit(checkpointName);
}
else {
throw new GitException("Neither commit, nor branch exists named '" + checkpointName + "'");
}
// Update index file
TreeNode root = headFile.loadTree();
indexFile.setEntries(root.getBlobs());
indexFile.save();
// Update working directory
clearWorkingDirectory();
indexFile.saveTrackedFilesToWorkingDir(getFullPathFromWorkingDirectory(), getFullPathFromRepository(BLOBS_DIR));
return "Reset successful" + System.lineSeparator();
}
public String log() throws GitException {
checkInitialized();
return logImpl(headFile.getCurrentCommitHash());
}
public String log(String commitHash) throws GitException {
checkInitialized();
return logImpl(commitHash);
}
public String log(int stepsBackwardsFromHead) throws GitException {
checkInitialized();
return logImpl(headFile.getShiftedCommitHash(stepsBackwardsFromHead));
}
private String logImpl(String startingCommit) throws GitException {
StringBuilder result = new StringBuilder();
Path fullPathToCommitsDir = getFullPathFromRepository(COMMITS_DIR);
String currentCommitHash = startingCommit;
while (!currentCommitHash.equals("")) {
CommitFile commit = CommitFile.load(fullPathToCommitsDir, currentCommitHash);
result.append(commit.getInfo()).append(System.lineSeparator());
currentCommitHash = commit.getParentCommitHash();
}
return result.toString();
}
/**
*
* @param checkpointName either commit hash or branch name (eg. master)
*/
public String checkout(String checkpointName) throws GitException {
checkInitialized();
return checkoutImpl(checkpointName);
}
public String checkout(int stepsBackwardsFromHead) throws GitException {
checkInitialized();
return checkoutImpl(headFile.getShiftedCommitHash(stepsBackwardsFromHead));
}
public String checkoutImpl(String checkpointName) throws GitException {
TreeNode prevRoot = headFile.loadTree();
// Update HEAD file
// branch
if (Files.exists(getFullPathFromRepository(BRANCHES_DIR, checkpointName))) {
headFile.setCurrentBranch(checkpointName);
}
// commit
else if (Files.exists(getFullPathFromRepository(COMMITS_DIR, checkpointName))) {
headFile.setCurrentCommitAsDetached(checkpointName);
}
else {
throw new GitException("Neither commit, nor branch exists named '" + checkpointName + "'");
}
// Update index file
TreeNode root = headFile.loadTree();
Map<String, String> checkoutBlobs = root.getBlobs();
indexFile.setEntries(checkoutBlobs);
indexFile.save();
// add new files from checkout commit/branch
indexFile.saveTrackedFilesToWorkingDir(getFullPathFromWorkingDirectory(), getFullPathFromRepository(BLOBS_DIR));
// remove all files from working directory, that are in `prevRoot` but not in `root`
Map<String, String> prevBlobs = prevRoot.getBlobs();
for (var entry : prevBlobs.entrySet()) {
String filename = entry.getKey();
if (!checkoutBlobs.containsKey(filename)) {
try {
Files.delete(getFullPathFromWorkingDirectory(filename));
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
}
removeEmptyWorkingDirectories(getFullPathFromWorkingDirectory());
return "Checkout completed successful" + System.lineSeparator();
}
public String checkout(List<String> filenames) throws GitException {
checkInitialized();
TreeNode root = headFile.loadTree();
Map<String, String> blobs = root.getBlobs();
for (String filename : filenames) {
if (!blobs.containsKey(filename)) {
throw new GitException("Filename '" + filename + "' is not recognized by git");
}
}
for (String filename : filenames) {
String hash = blobs.get(filename);
try {
byte[] fileBytes = Files.readAllBytes(getFullPathFromRepository(BLOBS_DIR, hash));
Files.write(
getFullPathFromWorkingDirectory(filename),
fileBytes
);
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
return "Checkout completed successful" + System.lineSeparator();
}
public String createBranch(String branchName) throws GitException {
if (headFile.branchExists(branchName)) {
throw new GitException("Branch '" + branchName + "' already exists");
}
try {
Path branchFile = Files.createFile(getFullPathFromRepository(BRANCHES_DIR, branchName));
Files.write(branchFile, headFile.getCurrentCommitHash().getBytes());
// the return message of this command is pretty weird, considering that we checkout new branch by default
// according to the tests
headFile.setCurrentBranch(branchName);
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
return
"Branch new-feature created successfully" + System.lineSeparator() +
"You can checkout it with 'checkout " + branchName + "'" + System.lineSeparator();
}
public String showBranches() throws GitException {
StringBuilder content = new StringBuilder();
content.append("Available branches:").append(System.lineSeparator());
List<Path> result;
try (Stream<Path> walk = Files.walk(getFullPathFromRepository(BRANCHES_DIR))) {
result = walk.filter(Files::isRegularFile)
.collect(Collectors.toList());
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
result.forEach(path -> {
content.append(path.getFileName().toString()).append(System.lineSeparator());
});
return content.toString();
}
public String removeBranch(String branchName) throws GitException {
if (!headFile.branchExists(branchName)) {
throw new GitException("Branch '" + branchName + "' does not exist");
}
if (headFile.getCurrentBranch().equals(branchName)) {
throw new GitException("Cannot remove current branch");
}
try {
Files.delete(getFullPathFromRepository(BRANCHES_DIR, branchName));
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
return "Branch " + branchName + " removed successfully" + System.lineSeparator();
}
public String merge(String otherBranchName) throws GitException {
// Нуууу, я почитал, как это делать:
// за 1 балл, пожалуй, откажусь + уже нет ментальных сил это реализовывать((
throw new UnsupportedOperationException();
}
public String getRelativeRevisionFromHead(int n) throws GitException {
return headFile.getShiftedCommitHash(n);
}
/*------- Helper methods ------------------------------------*/
private Path getFullPathFromRepository(String... paths) {
String prefix = Path.of(workingDir, REPOSITORY_DIR).toString();
return Path.of(prefix, paths);
}
private Path getFullPathFromWorkingDirectory(String... paths) {
return Path.of(workingDir, paths);
}
private void checkInitialized() throws GitException {
if (!isInitialized) {
throw new GitException("MiniGit repository not initialized");
}
}
/**
* Flattens the directories that {@code entryNames contain}, meaning goes inside of them while no directories left,
* the file names are built respectively
*/
private Map<String, File> getPureFiles(List<String> entryNames) throws GitException {
Map<String, File> files = new HashMap<>();
for (String name : entryNames) {
files.put(name, getFullPathFromWorkingDirectory(name).toFile());
}
// TODO: Do I have to check for files existance?
// MiniGitUtils.checkFilesExists(files.values().stream().toList());
return collectPureFiles("", files);
}
/**
* For every {@code File} that is a directory goes inside of it recursively and collects pure files from it
*/
private Map<String, File> collectPureFiles(String prefix, Map<String, File> entryFiles) {
Map<String, File> result = new HashMap<>();
entryFiles.forEach((name, file) -> {
if (file.isDirectory()) {
if (file.getName().equals(REPOSITORY_DIR)) {
return;
}
result.putAll(collectPureFiles(
prefix + file.getName() + "/",
Arrays.stream(Objects.requireNonNull(file.listFiles())).collect(Collectors.toMap(
File::getName,
Function.identity()
))
));
}
else {
// replacing './' symbol in path, so that we will not have extra tree-nodes for '.' folders
if (prefix.isEmpty()) {
result.put(name.replace("./", ""), file);
}
else {
result.put((prefix + name).replace("./", ""), file);
}
}
});
return result;
}
/**
*
* @param content
* @return {@code true} if some data was appended, otherwise {@code false}
*/
private boolean appendStatus(
StringBuilder content,
Map<FileStatus, List<String>> files,
String title
) {
String filesNew = collectFilesStatus(files.get(FileStatus.NEW));
String filesModified = collectFilesStatus(files.get(FileStatus.MODIFIED));
String filesDeleted = collectFilesStatus(files.get(FileStatus.DELETED));
boolean filesAdded = false;
if (filesModified.length() + filesNew.length() + filesDeleted.length() != 0) {
filesAdded = true;
content.append(title).append(System.lineSeparator()).append(System.lineSeparator());
if (!filesNew.isEmpty()) {
content.append("New files:").append(System.lineSeparator())
.append(filesNew).append(System.lineSeparator());
}
if (!filesModified.isEmpty()) {
content.append("Modified files:").append(System.lineSeparator())
.append(filesModified).append(System.lineSeparator());
}
if (!filesDeleted.isEmpty()) {
content.append("Removed files:").append(System.lineSeparator())
.append(filesDeleted).append(System.lineSeparator());
}
}
return filesAdded;
}
private String collectFilesStatus(List<String> files) {
StringBuilder result = new StringBuilder();
for (String filename : files) {
result.append("\t").append(filename).append(System.lineSeparator());
}
return result.toString();
}
private void clearWorkingDirectory() throws GitException {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(getFullPathFromWorkingDirectory())) {
for (Path entry : stream) {
if (entry.toString().contains(REPOSITORY_DIR)) {
continue;
}
if (Files.isDirectory(entry)) {
deleteRecursively(entry);
}
else {
Files.delete(entry);
}
}
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
private void deleteRecursively(Path path) throws IOException {
if (Files.isDirectory(path)) {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(path)) {
for (Path file : stream) {
deleteRecursively(file);
}
}
}
Files.deleteIfExists(path);
}
private void removeEmptyWorkingDirectories(Path currentDir) throws GitException {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(currentDir)) {
for (Path entry : stream) {
if (entry.toString().contains(REPOSITORY_DIR)) {
continue;
}
if (Files.isDirectory(entry)) {
removeEmptyWorkingDirectories(entry);
}
}
if (countEntries(currentDir) == 0) {
Files.delete(currentDir);
}
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
private int countEntries(Path directory) throws GitException {
int count = 0;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path entry : stream) {
count++;
}
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
return count;
}
}
package ru.hse.mit.git.components.utils;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import org.apache.commons.io.FileUtils;
import ru.hse.mit.git.GitException;
public class MiniGitUtils {
public static String getHashFromBytes(byte[] bytes) {
// set encryption algorithm
MessageDigest md = null;
try {
md = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
byte[] hashBytes = md.digest(bytes);
// Convert the hash bytes to a hexadecimal string
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
public static void checkFileExists(File file) throws GitException {
if (!file.exists()) {
throw new GitException("File '" + file.getName() + "' does not exists");
}
}
public static void checkFilesExists(List<File> files) throws GitException {
for (File file : files) {
if (!file.exists()) {
throw new GitException("File '" + file.getName() + "' does not exists");
}
}
}
public static byte[] getFileBytes(Path fullPath) throws GitException {
File file = new File(fullPath.toString());
checkFileExists(file);
try {
return FileUtils.readFileToByteArray(file);
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
}
}
package ru.hse.mit.git.components.graph;
import java.util.Optional;
public class Node {
public enum NodeType {
TREE_NODE,
BLOB_NODE
}
protected Optional<String> hash = Optional.empty();
protected String nodeName;
protected NodeType type;
public Node(String nodeName, NodeType type) {
this.nodeName = nodeName;
this.type = type;
}
public String getName() {
return nodeName;
}
public Optional<String> getHash() {
return hash;
}
public NodeType getType() {
return type;
}
}
package ru.hse.mit.git.components.fs;
import java.nio.file.Path;
import org.jetbrains.annotations.NotNull;
import ru.hse.mit.git.GitException;
import ru.hse.mit.git.components.utils.MiniGitUtils;
public class TreeFile extends AbstractEditableFile {
private final byte[] fileBytes;
public TreeFile(Path fullPathToDir, byte @NotNull [] fileBytes) {
this.filename = MiniGitUtils.getHashFromBytes(fileBytes);
this.fullPath = Path.of(fullPathToDir.toString(), filename);
this.fileBytes = fileBytes;
}
public void save() throws GitException {
save(fileBytes);
}
}
package ru.hse.mit.git.components.graph;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import ru.hse.mit.git.GitException;
import ru.hse.mit.git.components.fs.TreeFile;
import ru.hse.mit.git.components.utils.MiniGitUtils;
public class TreeNode extends Node {
private final Map<String, Node> children = new HashMap<>();
private String content = "";
public static TreeNode createRoot() {
return new TreeNode("");
}
public TreeNode(String nodeName) {
super(nodeName, NodeType.TREE_NODE);
}
/**
*
* @return blobs entries: { filename in working directory, hash }
*/
public Map<String, String> getBlobs() {
return getBlobs("");
}
private Map<String, String> getBlobs(String namePrefix) {
Map<String, String> result = new HashMap<>();
for (var childEntry : children.entrySet()) {
String childName = childEntry.getKey();
Node childNode = childEntry.getValue();
switch (childNode.getType()) {
case TREE_NODE -> result.putAll(((TreeNode)childNode).getBlobs(namePrefix + childName + "/"));
case BLOB_NODE -> result.put(namePrefix + childName, childNode.getHash().get());
}
}
return result;
}
public static TreeNode loadTree(Path pathToTreesDir, String hash) throws GitException {
return loadTree(pathToTreesDir, hash, "");
}
private static TreeNode loadTree(Path pathToTreesDir, String hash, String name) throws GitException {
TreeNode node = new TreeNode(name);
node.hash = Optional.of(hash);
try(Stream<String> stream = Files.lines(Path.of(pathToTreesDir.toString(), hash))) {
List<String> lines = stream.toList();
for (String line : lines) {
String[] data = line.split(" ");
String childType = data[0];
String childHash = data[1];
String childName = data[2];
Node child;
if (childType.equals("tree")) {
child = loadTree(pathToTreesDir, childHash, childName);
}
else {
child = new BlobNode(childName, childHash);
}
node.children.put(childName, child);
}
} catch (IOException e) {
throw new GitException(e.getMessage(), e.getCause());
}
return node;
}
public void addChildren(int index, List<String> names, String blobHash) {
if (index == names.size() - 1) {
addBlob(names.get(index), blobHash);
return;
}
String treeNodeName = names.get(index);
if (!children.containsKey(treeNodeName)) {
children.put(treeNodeName, new TreeNode(treeNodeName));
}
((TreeNode)children.get(treeNodeName)).addChildren(index + 1, names, blobHash);
}
public void addBlob(String name, String hash) {
if (!children.containsKey(name)) {
children.put(name, new BlobNode(name, hash));
}
}
public void buildGraph() {
StringBuilder content = new StringBuilder();
for (var childEntry : children.entrySet()) {
String childName = childEntry.getKey();
Node childNode = childEntry.getValue();
switch (childNode.type) {
case TREE_NODE -> {
TreeNode treeNode = (TreeNode) childNode;
treeNode.buildGraph();
String hash = treeNode.getHash().get();
content
.append("tree ")
.append(hash).append(" ")
.append(childName)
.append(System.lineSeparator());
}
case BLOB_NODE -> {
content
.append("blob ")
.append(childNode.getHash().get()).append(" ")
.append(childName)
.append(System.lineSeparator());
}
}
}
String currentNodeHash = MiniGitUtils.getHashFromBytes(content.toString().getBytes());
this.hash = Optional.of(currentNodeHash);
this.content = content.toString();
}
public void saveGraph(Path fullPath) throws GitException {
for (var childEntry : children.entrySet()) {
Node childNode = childEntry.getValue();
// only save tree-nodes, because blob-nodes are already saved
if (childNode.type == NodeType.TREE_NODE) {
TreeNode treeNode = (TreeNode)childNode;
treeNode.saveGraph(fullPath);
}
}
TreeFile treeFile = new TreeFile(fullPath, content.getBytes());
treeFile.save();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment