diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e796965 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: java +jdk: + - oraclejdk8 \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..7408979 --- /dev/null +++ b/build.gradle @@ -0,0 +1,25 @@ +group 'ru.spbau.gusev.vcs' + +apply plugin: 'java' + +repositories { + mavenCentral() +} + +dependencies { + testCompile group: 'junit', name: 'junit', version: '4.11' + testCompile group: 'org.mockito', name: 'mockito-core', version: '2.7.21' + compile group: 'com.google.code.findbugs', name: 'jsr305', version: '3.0.1' +} + +jar { + manifest { + attributes( + 'Main-Class': 'Main' + ) + } +} + +test { + forkEvery = 1 +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..6ffa237 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..541c7eb --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Mar 08 19:08:08 MSK 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-3.1-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..9aa616c --- /dev/null +++ b/gradlew @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [[ "$(uname)" == "Darwin" ]] && [[ "$HOME" == "$PWD" ]]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..1517519 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'VCS' + diff --git a/src/main/java/Main.java b/src/main/java/Main.java new file mode 100644 index 0000000..9c9be6d --- /dev/null +++ b/src/main/java/Main.java @@ -0,0 +1,402 @@ +import java.util.List; +import ru.spbau.gusev.vcs.*; + +import javax.annotation.Nonnull; + +public class Main { + public static void main(String[] args) { + Command requestedCommand; + if (args.length == 0) { + requestedCommand = Command.help; + } else { + try { + requestedCommand = Command.valueOf(args[0]); + } catch (EnumConstantNotPresentException | IllegalArgumentException e) { + requestedCommand = Command.help; + } + } + + try { + requestedCommand.exec(args); + } catch (VCS.FileSystemError e) { + System.out.println("File system error:" + e.getMessage()); + } catch (Throwable t) { + System.out.println("Unknown error:" + t.getMessage()); + } + } + + private enum Command{ + init { + @Override + protected void exec(String[] args) { + if (args.length == 1) { + help.exec(args); + } else { + try { + VCS.createRepo(args[1]); + } catch (VCS.RepoAlreadyExistsException e) { + System.out.println("Repo already exists."); + } + } + } + + @Override + protected String getDesc() { + return "init - initialises repo with the given username"; + } + }, + + commit { + @Override + protected void exec(String[] args) { + if (args.length == 1) { + System.out.println("Commit message required."); + } else { + try { + VCS.commit(args[1]); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + e.getMessage()); + } catch (VCS.BadPositionException e) { + System.out.println("You must be in the head of a branch to commit."); + } catch (VCS.NothingToCommitException e) { + System.out.println("Nothing to commit."); + } + } + } + + @Override + protected String getDesc() { + return "commit - commits changes"; + } + }, + + add { + @Override + protected void exec(String[] args) { + if (args.length == 1) { + help.exec(args); + } else { + for (int i = 1; i < args.length; i++) { + try { + VCS.addFile(args[i]); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + e.getMessage()); + } catch (VCS.NoSuchFileException e) { + System.out.println("File " + args[i] + " " + + "cannot be found."); + } + } + } + } + + @Override + protected String getDesc() { + return "add - adds the file to the next commit"; + } + }, + + checkout { + @Override + protected void exec(String[] args) { + if (args.length == 3) { + try { + switch (args[1]) { + case "branch": { + VCS.checkoutBranch(args[2]); + break; + } + case "commit": { + VCS.checkoutCommit(Integer.valueOf(args[2])); + break; + } + default: { + help.exec(args); + } + } + } catch (NumberFormatException e) { + System.out.println("Commit ID must be a number."); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + + e.getMessage()); + } catch (VCS.NoSuchBranchException e) { + System.out.println("A branch called " + + args[2] + " does not exist."); + } catch (VCS.NoSuchCommitException e) { + System.out.println("A commit with number " + + args[2] + " does not exist."); + } + } else { + help.exec(args); + } + } + + @Override + protected String getDesc() { + return "checkout commit - returns the repo to the state of " + + "the specified commit\n" + + "checkout branch - checks out the head of the given " + + "branch"; + } + }, + + login { + @Override + protected void exec(@Nonnull String[] args) { + if (args.length < 2) { + help.exec(args); + } else { + try { + VCS.setUserName(args[1]); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + e.getMessage()); + } + } + } + + @Override + protected String getDesc() { + return "login - changes current username to the given one"; + } + }, + + merge { + @Override + protected void exec(@Nonnull String[] args) { + if (args.length < 2) { + System.out.println("Specify a branch to merge."); + } else { + try { + VCS.merge(args[1]); + } catch (VCS.NoSuchBranchException e) { + System.out.println("Branch " + args[1] + " does not exist."); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo:" + e.getMessage()); + } catch (VCS.BadPositionException e) { + System.out.println("You must be in the head of a branch " + + "to merge another one into it."); + } + } + } + + @Override + protected String getDesc() { + return "merge - merges the given branch into current"; + } + }, + + reset { + @Override + protected void exec(String[] args) { + if (args.length < 2) { + System.out.println("Specify a file ro reset."); + } else { + try { + VCS.reset(args[1]); + } catch (VCS.NoSuchFileException e) { + System.out.println("The current commit does not contain a file called " + + args[1]); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + e.getMessage()); + } + } + } + + @Override + protected String getDesc() { + return "reset - resets the file to its state in the " + + "last commit(if the file is not present in the last commit" + + ", it is removed)"; + } + }, + + rm { + @Override + protected void exec(String[] args) { + if (args.length < 2) { + System.out.println("Specify a file ro remove."); + } else { + try { + VCS.remove(args[1]); + } catch (VCS.NoSuchFileException e) { + System.out.println("No file called " + args[1] + " found " + + "in staging zone."); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + e.getMessage()); + } + } + } + + @Override + protected String getDesc() { + return "rm - removes the file with given name from the " + + "working directory and stage"; + } + }, + + help { + @Override + protected void exec(String[] args) { + System.out.println("Commands include:"); + for (Command command: Command.values()) { + System.out.println(command.getDesc()); + } + } + + @Override + protected String getDesc() { + return "help - show commands descriptions"; + } + }, + + log { + @Override + protected void exec(String[] args) { + try { + List commits = VCS.getLog(); + String curBranchName = VCS.getCurBranch(); + if (commits.isEmpty()) { + System.out.printf("No commits in branch %s yet.\n", curBranchName); + } else { + System.out.printf("Commits in branch %s:\n", curBranchName); + System.out.printf("%4s%12s %12s%8s %s\n", "ID", "Author", + "Date", "Time", "Message"); + commits.forEach(commit -> + System.out.printf("%4d%12s %3$tF %3$tT %4$s\n", + commit.getNumber(), commit.getAuthor(), + commit.getTime(), commit.getMessage()) + ); + } + } catch (VCS.BadRepoException | VCS.NoSuchBranchException e) { + System.out.println("Incorrect repo: " + e.getMessage()); + } + + } + + + @Override + protected String getDesc() { + return "log - shows commit history in current branch"; + } + }, + + status { + @Override + protected void exec(String[] args) { + try { + System.out.println("Changed files:"); + VCS.getChanged().forEach(System.out::println); + + System.out.println("\nNew files:"); + VCS.getCreated().forEach(System.out::println); + + System.out.println("\nStaged files:"); + VCS.getStaged().forEach(System.out::println); + + System.out.println("\nRemoved files:"); + VCS.getRemoved().forEach(System.out::println); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + e.getMessage()); + } + } + + @Override + protected String getDesc() { + return "status - to show information about new, changed, deleted and" + + " staged files"; + } + }, + + branch { + @Override + protected void exec(String[] args) { + if (args.length > 1) { + switch (args[1]) { + case "create": { + try { + if (args.length > 2) { + VCS.createBranch(args[2]); + } + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo"); + } catch (VCS.BranchAlreadyExistsException e) { + System.out.println("A branch with this name " + + "already exists."); + } + break; + } + case "delete": { + if (args[2].equals("master")) { + System.out.println("Branch master cannot be deleted."); + } else { + try { + if (args.length > 2) { + VCS.deleteBranch(args[2]); + } + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + + e.getMessage()); + } catch (VCS.NoSuchBranchException e) { + System.out.println("A branch called " + + args[2] + " does not exist."); + } catch (VCS.BadPositionException e) { + System.out.println("Current branch cannot be" + + " deleted."); + } + } + break; + } + case "list": { + try { + String currentBranch = VCS.getCurBranch(); + VCS.getBranchNames().forEach(branchName -> { + if (branchName.equals(currentBranch)) { + System.out.println("* " + branchName); + } else { + System.out.println(" " + branchName); + } + }); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + + e.getMessage()); + } + break; + } + default: { + help.exec(args); + } + } + } else { + help.exec(args); + } + } + + @Override + protected String getDesc() { + return "branch create - creates a branch with " + + "provided name\n" + + "branch delete - deletes specified branch\n" + + "branch list - lists all existing branches"; + } + }, + + clean { + @Override + protected void exec(String[] args) { + try { + VCS.clean(); + } catch (VCS.BadRepoException e) { + System.out.println("Incorrect repo: " + e.getMessage()); + } + } + + @Override + protected String getDesc() { + return "clean - removes all untracked files"; + } + }; + + protected abstract void exec(String[] args); + + protected abstract String getDesc(); + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/Branch.java b/src/main/java/ru/spbau/gusev/vcs/Branch.java new file mode 100644 index 0000000..c6d15b9 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/Branch.java @@ -0,0 +1,213 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Scanner; +import java.util.stream.Collectors; + +/** + * A class representing a branch in the repository. + */ +public class Branch { + private static final String REPO_DIR = ".vcs"; + private static final String BRANCHES_DIR = "branches"; + + private final String name; + private final Path commitsListPath; + private final Repository repository; + + /** + * Creates a Branch object for the branch with the given name in the + * given repository. + * @param name the name of the branch. + * @param repository the repository containing the branch. + * @throws VCS.NoSuchBranchException if a branch with the given name does + * not exist in the given repository. + */ + private Branch(@Nonnull String name, @Nonnull Repository repository) + throws VCS.NoSuchBranchException { + this.name = name; + this.repository = repository; + commitsListPath = Paths.get(REPO_DIR, BRANCHES_DIR, name); + if (Files.notExists(commitsListPath)) { + throw new VCS.NoSuchBranchException(); + } + } + + /** + * Creates a new branch in the repository. + * @param name the name for the new branch. + * @param repository the repository in which the new branch should be + * located. + * @return an object representing the new branch. + * @throws VCS.BranchAlreadyExistsException if a branch with the given + * name already exists in the repository. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + @Nonnull + protected static Branch create(@Nonnull String name, + @Nonnull Repository repository) + throws VCS.BranchAlreadyExistsException, VCS.BadRepoException { + if (name.isEmpty()) { + throw new IllegalArgumentException("Empty branch name."); + } + + Path descPath = Paths.get(REPO_DIR, BRANCHES_DIR, name); + if (Files.exists(descPath)) { + throw new VCS.BranchAlreadyExistsException(); + } + if (!Files.isDirectory(descPath.getParent())) { + throw new VCS.BadRepoException("Branches folder not found."); + } + try { + Files.write(descPath, (repository.getCurrentCommitNumber().toString() + + '\n').getBytes()); + } catch (IOException e) { + throw new VCS.FileSystemError("Branch description creation error."); + } + try { + return new Branch(name, repository); + } catch (VCS.NoSuchBranchException e) { + throw new VCS.BadRepoException("Branch creation failed."); + } + } + + /** + * Reads an existing branch from the repo. + * @param name the name of the branch to get. + * @param repository the repository that the branch belongs to. + * @return an object representing the requested branch. + * @throws VCS.NoSuchBranchException if the requested branch does not exist. + */ + @Nonnull + protected static Branch getByName(@Nonnull String name, + @Nonnull Repository repository) + throws VCS.NoSuchBranchException { + return new Branch(name, repository); + } + + /** + * Gets the name of the branch. + * @return the name of the branch. + */ + @Nonnull + protected String getName() { + return name; + } + + /** + * Adds a commit to the end of the branch. + * @param newCommit a commit to add. + */ + protected void addCommit(@Nonnull Commit newCommit) { + try { + Files.write(commitsListPath, (newCommit.getNumber().toString() + '\n') + .getBytes(), StandardOpenOption.APPEND); + } catch (IOException e) { + throw new VCS.FileSystemError("Error writing commit number to branch's " + + "list."); + } + } + + /** + * Gets the number of hte last commit in the branch. + * @return the number of hte last commit in the branch. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + @Nonnull + protected Integer getHeadNumber() throws VCS.BadRepoException { + String headNumber = "-1"; + try (Scanner scanner = new Scanner(commitsListPath)) { + while (scanner.hasNext()) { + headNumber = scanner.next(); + } + } catch (IOException e) { + throw new VCS.FileSystemError("Error reading branch's commits list."); + } + try { + return (Integer.valueOf(headNumber)); + } catch (NumberFormatException e) { + throw new VCS.BadRepoException("Incorrect branch description format."); + } + } + + /** + * Gets the branch's head commit. + * @return a Commit object representing the branch's head commit. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + @Nonnull + protected Commit getHead() throws VCS.BadRepoException { + try { + return Commit.read(getHeadNumber(), repository); + } catch (VCS.NoSuchCommitException e) { + throw new VCS.BadRepoException("Branch's head commit not found."); + } + } + + /** + * Makes a log of commits in the branch. + * @return a list with descriptions of all commits of the branch. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + @Nonnull + protected List getLog() throws VCS.BadRepoException { + try { + return Files.lines(commitsListPath).skip(1).map(number -> { + try { + Commit commit = Commit.read(Integer.valueOf(number), + repository); + return new VCS.CommitDescription(commit); + } catch (VCS.NoSuchCommitException | VCS.BadRepoException e) { + throw new Error(); + } + }).collect(Collectors.toList()); + } catch (IOException e) { + throw new VCS.FileSystemError("Error reading branch's commit list."); + } catch (Error e) { + throw new VCS.BadRepoException("Error reading commit data."); + } + } + + /** + * Deletes the branch from the repository. Deleting the master branch is + * impossible. + * @throws VCS.BadRepoException if the repository folder is corrupt. + * @throws VCS.BadPositionException in case of an attempt to remove the current + * branch. + * @throws IllegalArgumentException in case of attempt to delete the master branch. + */ + void delete() throws VCS.BadRepoException, VCS.BadPositionException { + try { + Files.lines(commitsListPath).forEach(commit -> { + try { + Commit.read(Integer.valueOf(commit), repository).delete(); + } catch (VCS.NoSuchCommitException | VCS.BadRepoException e) { + throw new Error(); + } + }); + Files.delete(commitsListPath); + } catch (IOException e) { + throw new VCS.FileSystemError(); + } catch (Error e) { + throw new VCS.BadRepoException("Error deleting commit."); + } + } + + /** + * Compares this object with another Branch object. Two Branch objects are considered + * equal if their names coincide. + * @param o object to compare with. + * @return true if the given object is an equal Branch object, false if is is not equal + * or is not a Branch object. + */ + @Override + public boolean equals(Object o) { + return o instanceof Branch && ((Branch)o).name.equals(this.name); + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/Commit.java b/src/main/java/ru/spbau/gusev/vcs/Commit.java new file mode 100644 index 0000000..9840907 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/Commit.java @@ -0,0 +1,358 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A class representing a commit in the repository. + */ +public class Commit { + private static final String COMMIT_METADATA_FILE = "metadata"; + private static final String COMMIT_FILES_LIST = "files_list"; + + private final Integer number; + private final long creationTime; + private final String message; + private final Branch branch; + private final String author; + private final Integer father; + private final Path rootDir; + private final IntersectedFolder contentFolder; + private final Repository repository; + + /** + * Creates a Commit object writing its data to the disk. + * @param message a message for the new commit. + * @param repository the repository in which the commit should be located. + * @throws VCS.BadRepoException if the repository is corrupt. + * @throws VCS.BadPositionException if the head is not in the end of + * the current branch. + */ + private Commit(@Nonnull String message, @Nonnull Repository repository) + throws VCS.BadRepoException, VCS.BadPositionException { + if (Files.notExists(Paths.get(Repository.REPO_DIR_NAME, + Repository.COMMITS_DIR_NAME))) { + throw new VCS.BadRepoException("Commits directory not found."); + } + if (message.isEmpty()) { + throw new IllegalArgumentException("Empty commit message."); + } + + this.repository = repository; + this.number = repository.getCommitsNumber(); + this.branch = repository.getCurBranch(); + rootDir = Paths.get(Repository.REPO_DIR_NAME, + Repository.COMMITS_DIR_NAME, number.toString()); + creationTime = System.currentTimeMillis(); + this.message = message; + father = branch.getHeadNumber(); + this.author = repository.getUserName(); + + if (!repository.getCurrentCommitNumber().equals(father)) { + throw new VCS.BadPositionException("Attempt to create a commit not in " + + "the head of a branch."); + } + + try { + Files.createDirectory(rootDir); + + BufferedWriter metadataWriter = new BufferedWriter(new FileWriter( + rootDir.resolve(COMMIT_METADATA_FILE).toString())); + metadataWriter.write(String.valueOf(creationTime) + '\n'); + metadataWriter.write(branch.getName() + '\n'); + metadataWriter.write(author + '\n'); + metadataWriter.write(father.toString() + '\n'); + metadataWriter.write(message); + metadataWriter.close(); + + IntersectedFolderStorage storage = repository.getCommitStorage(); + contentFolder = new IntersectedFolder(storage, + rootDir.resolve(COMMIT_FILES_LIST)); + repository.getStagingZone().getFiles().forEach(contentFolder::add); + contentFolder.writeList(); + } catch (IOException | VCS.FileSystemError e) { + try { + HashedDirectory.deleteDir(rootDir); + } catch (IOException e1) { + e.addSuppressed(e1); + } + throw new VCS.FileSystemError("Error writing new commit data."); + } + } + + /** + * Creates a Commit instance for a commit already existing in a repository. + * @param number the number of commit to be read. + * @param repository the repository in which the commit is located. + * @throws VCS.NoSuchCommitException if a commit with the given number does not + * exist. + * @throws VCS.BadRepoException if the repository data folder is corrupt. + */ + private Commit(@Nonnull Integer number, @Nonnull Repository repository) + throws VCS.NoSuchCommitException, VCS.BadRepoException { + this.number = number; + this.repository = repository; + rootDir = Paths.get(Repository.REPO_DIR_NAME, + Repository.COMMITS_DIR_NAME, number.toString()); + + if (Files.notExists(rootDir)) { + throw new VCS.NoSuchCommitException(); + } + + try (Scanner metadataScanner = + new Scanner(rootDir.resolve(COMMIT_METADATA_FILE))) { + creationTime = metadataScanner.nextLong(); + branch = Branch.getByName(metadataScanner.next(), repository); + author = metadataScanner.next(); + father = Integer.valueOf(metadataScanner.next()); + + StringBuilder messageBuilder = new StringBuilder(); + metadataScanner.nextLine(); + messageBuilder.append(metadataScanner.nextLine()); + while (metadataScanner.hasNext()) { + messageBuilder.append('\n'); + messageBuilder.append(metadataScanner.nextLine()); + } + message = messageBuilder.toString(); + + contentFolder = new IntersectedFolder(repository.getCommitStorage(), + rootDir.resolve(COMMIT_FILES_LIST)); + } catch (IOException e) { + throw new VCS.FileSystemError("Error reading commit data."); + } catch (VCS.NoSuchBranchException e) { + throw new VCS.BadRepoException("Requested commit not found."); + } + } + + /** + * Makes a Commit writing its data to the disk. + * @param message a message for the new commit. + * @param repository the repository in which the commit should be located. + * @return a Commit object representing the newly created commit. + * @throws VCS.BadRepoException if the repository is corrupt. + * @throws VCS.BadPositionException if the head is not in the end of + * the current branch. + */ + protected static Commit create(@Nonnull String message, + @Nonnull Repository repository) + throws VCS.BadRepoException, VCS.BadPositionException { + Commit commit = new Commit(message, repository); + repository.getCurBranch().addCommit(commit); + repository.setCurrentCommit(commit); + repository.updateCommitCounter(commit.number + 1); + return commit; + } + + /** + * Reads an already existing commit from the repository. + * @param number the number of commit to be read. + * @param repository the repository in which the commit is located. + * @return a Commit object representing the Commit. + * @throws VCS.NoSuchCommitException if a commit with the given number + * does not exist. + * @throws VCS.BadRepoException if the repository data folder is corrupt. + */ + protected static Commit read(@Nonnull Integer number, + @Nonnull Repository repository) + throws VCS.NoSuchCommitException, VCS.BadRepoException { + return new Commit(number, repository); + } + + /** + * Gets the commit number. + * @return the commit number. + */ + @Nonnull + protected Integer getNumber() { + return number; + } + + /** + * Gets the commit creation time. Time is measured in millisecond from the beginning + * of the UNIX epoch. + * @return the commit creation time. + */ + @Nonnull + protected long getCreationTime() { + return creationTime; + } + + /** + * Gets the commit message. + * @return the commit message. + */ + @Nonnull + protected String getMessage() { + return message; + } + + /** + * Gets the branch that this commit belongs to. + * @return the branch that this commit belongs to. + */ + @Nonnull + protected Branch getBranch() { + return branch; + } + + /** + * Gets the commit author's username. + * @return the commit author's username. + */ + @Nonnull + protected String getAuthor() { + return author; + } + + /** + * Gets the parental commit, the commit which was the global head before this + * commit creation. + * @return the parental commit. + */ + @Nonnull + protected Commit getFather() throws VCS.BadRepoException { + try { + return Commit.read(father, repository); + } catch (VCS.NoSuchCommitException e) { + throw new VCS.BadRepoException("Error reading commit's father."); + } + } + + /** + * Removes all the commit's files from the given working directory. + * @param directory the directory from which the commit files should be removed. + */ + protected void removeFrom(@Nonnull WorkingDirectory directory) { + contentFolder.getFiles() + .forEach(file -> directory.delete(file.getName())); + } + + /** + * Copies all the files from the commit to the given working directory and sets + * this commit as global head. + * @param directory the directory which the commit files should be copied to. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + protected void checkout(@Nonnull WorkingDirectory directory, + @Nonnull StagingZone stagingZone) throws + VCS.BadRepoException { + contentFolder.getFiles().peek(directory::add) + .forEach(stagingZone::add); + } + + /** + * Restores the history between the initial commit and the current one. + * @return a list containing all current commit's predessors sorted by + * creation time. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + @Nonnull + protected List getPedigree() throws VCS.BadRepoException { + ArrayList pedigree = new ArrayList<>(); + pedigree.add(this); + Commit pos = this; + while (!pos.getNumber().equals(0)) { + pos = pos.getFather(); + pedigree.add(pos); + } + Collections.reverse(pedigree); + return pedigree; + } + + /** + * Compares two Commit objects. Two commit objects are considered equal if their + * numbers coincide. + * @param o the object to compare with. + * @return true if the given object is an equal Commit object, false if it is not + * equal or is not a Commit object. + */ + @Override + public boolean equals(Object o) { + return o instanceof Commit && ((Commit)o).number.equals(this.number); + } + + /** + * Gets a stream with all the files in the commit. + * @return a Stream with TrackedFile objects for all files in the repository. + */ + @Nonnull + public Stream getFiles() { + return contentFolder.getFiles(); + } + + /** + * Restores file with given path in the working directory to the its condition at + * the moment of commit creation. + * @param filePath the path to the file to restore. + * @param directory the directory where the file should be reset. + */ + protected void resetFile(@Nonnull Path filePath, + @Nonnull WorkingDirectory directory, + @Nonnull StagingZone stagingZone) { + if (contentFolder.contains(filePath)) { + TrackedFile file = contentFolder.getFile(filePath); + stagingZone.add(file); + directory.add(file); + } else { + stagingZone.removeFile(filePath); + directory.delete(filePath); + } + } + + /** + * Gets a HashedFile representation of a file from this commit by its path. + * @param filePath the path to the file. + * @return a HashedFile representation of the file or null if the file doesn't exist. + */ + @Nullable + protected TrackedFile getFile(@Nonnull Path filePath) { + if (!contentFolder.contains(filePath)) { + return null; + } + + return contentFolder.getFile(filePath); + } + + /** + * Lists all the files that have been removed from the repository since creation of + * this commit. + * @return a List with all removed files. + */ + @Nonnull + protected List getRemovedFiles(StagingZone stagingZone) throws VCS.BadRepoException { + return contentFolder.getFiles() + .filter(file -> !stagingZone.contains(file.getName())) + .map(TrackedFile::getName) + .map(Path::toString) + .collect(Collectors.toList()); + } + + /** + * Deletes the commit from the repository. + */ + protected void delete() { + contentFolder.getFiles() + .forEach(file -> { + try { + contentFolder.delete(file.getName()); + } catch (VCS.NoSuchFileException e) { + throw new VCS.FileSystemError("A file from commit cannot " + + "be found."); + } + }); + try { + HashedDirectory.deleteDir(rootDir); + } catch (IOException e) { + throw new VCS.FileSystemError("Error deleting commit's folder."); + } + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/HashedDirectory.java b/src/main/java/ru/spbau/gusev/vcs/HashedDirectory.java new file mode 100644 index 0000000..4089b53 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/HashedDirectory.java @@ -0,0 +1,207 @@ +package ru.spbau.gusev.vcs; + +import java.io.*; +import java.nio.file.*; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.annotation.Nonnull; + +/** + * A class maintaining a folder where hashes are calculated for all files. + */ +public class HashedDirectory { + private final Path dir; + private final Path hashesPath; + private final Map hashes; + + /** + * Gets the path to the directory. + * @return the directory path. + */ + public Path getDir() { + return dir; + } + + /** + * Gets the path to the files list. + * @return the path to the list of files. + */ + public Path getHashesPath() { + return hashesPath; + } + + /** + * Creates a HashedDirectory object with supplied directory and supplied file + * with hashes. + * @param dir the path to the directory. + * @param hashesPath the path to the file with hashes. + */ + protected HashedDirectory(@Nonnull Path dir, @Nullable Path hashesPath) { + this.dir = dir; + this.hashesPath = hashesPath; + hashes = new LinkedHashMap<>(); + try { + if (hashesPath != null) { + if (Files.exists(hashesPath)) { + for (String line : Files.readAllLines(hashesPath)) { + String[] parts = line.split(" "); + hashes.put(Paths.get(parts[0]), + new HashedFile(Paths.get(parts[0]), dir, parts[1])); + } + } else { + Files.createFile(hashesPath); + } + } + } catch (IOException e) { + throw new VCS.FileSystemError(); + } + } + + /** + * Removes all the files from the given directory. + * @throws IOException if a deletion error occurs. + */ + protected static void wipeDir(@Nonnull File dir) throws IOException { + for (File file: dir.listFiles()) { + if (file.isDirectory()) { + wipeDir(file); + } + file.delete(); + } + } + + /** + * Recursively deletes the given folder. + * @param dir the directory to delete. + * @throws IOException if a deletion error occurs. + */ + protected static void deleteDir(@Nonnull Path dir) throws IOException { + wipeDir(dir.toFile()); + Files.delete(dir); + } + + /** + * Recursively deletes the given folder. + * @param dir the path to the directory to delete. + * @throws IOException if a deletion error occurs. + */ + protected static void deleteDir(@Nonnull String dir) throws IOException { + deleteDir(Paths.get(dir)); + } + + /** + * Writes the file hashes to the hashes file. + */ + protected void writeHashes() { + if (hashesPath == null) { + throw new IllegalStateException("Attempt to write hashes in a " + + "HashedDirectory without hashes list path specified."); + } + try { + if (Files.exists(hashesPath)) { + Files.delete(hashesPath); + } + BufferedWriter hashWriter = Files.newBufferedWriter(hashesPath, + StandardOpenOption.WRITE, StandardOpenOption.CREATE); + hashes.forEach((path, s) -> { + try { + hashWriter.write(path.toString() + ' ' + s.getHash() + '\n'); + } catch (IOException e) { + throw new VCS.FileSystemError("Error writing hashes to " + + path.toString()); + } + }); + hashWriter.close(); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Gets a map with file descriptions. + * @return a Map from file path in the directory to its hash and absolute path. + */ + @Nonnull + protected Map getFileDescriptions() { + return hashes; + } + + /** + * Removes all files from the directory. + */ + protected void clear() { + try { + wipeDir(dir.toFile()); + hashes.clear(); + } catch (IOException e) { + throw new VCS.FileSystemError(); + } + } + + /** + * Copies a file represented by a HashedFile object into the folder. + * @param file the file to copy. + */ + protected void add(@Nonnull TrackedFile file) { + try { + Path newFilePath = dir.resolve(file.getName()); + + Files.createDirectories(newFilePath.getParent()); + Files.copy(file.getLocation(), newFilePath, + StandardCopyOption.REPLACE_EXISTING); + + hashes.put(file.getName(), new HashedFile(file.getName(), dir, file.getHash())); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Makes a stream containing all files from the directory in HashedFile form. + * @return a stream with all files. + */ + @Nonnull + protected Stream getFiles() { + return hashes.values().stream(); + } + + /** + * Deletes the file by the given path from the directory. + * @param path the path pointing to the file that should be deleted. + * @throws VCS.NoSuchFileException if the given path does not point to a file in + * the directory. + */ + protected void deleteFile(@Nonnull Path path) throws VCS.NoSuchFileException { + if (Files.notExists(dir.resolve(path))) { + throw new VCS.NoSuchFileException(); + } + + hashes.remove(path); + try { + Files.delete(dir.resolve(path)); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Checks if a file with the given path exists in the directory. + * @param filePath the path to the file to check. + * @return true if such file exists, false if it doesn't. + */ + protected boolean contains(@Nonnull Path filePath) { + return Files.isRegularFile(dir.resolve(filePath)); + } + + /** + * Gets a HashedFile representation of a file from the directory by its path. + * @param filePath the path to the file in directory. + * @return a HashedFile representation of the file or null if the file doesn't exist. + */ + @Nullable + protected HashedFile getHashedFile(@Nonnull Path filePath) { + return hashes.get(filePath); + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/HashedFile.java b/src/main/java/ru/spbau/gusev/vcs/HashedFile.java new file mode 100644 index 0000000..ca22b27 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/HashedFile.java @@ -0,0 +1,122 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.FileInputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * A class representing a file with calculated hash. + */ +public class HashedFile implements TrackedFile { + private final String hash; + private final Path path; + private final Path dir; + + /** + * Creates an object from file path an already calculated hash. + * @param path path to file. + * @param dir path to the hashed directory which the file belongs to. + * @param hash hash of file. + */ + HashedFile(@Nonnull Path path, @Nonnull Path dir, @Nonnull String hash) { + this.hash = hash; + this.path = path; + this.dir = dir; + } + + /** + * Creates an object calculating hash. + * @param path path to the file. + * @param dir path to the hashed directory which the file belongs to. + */ + HashedFile(@Nonnull Path path, @Nonnull Path dir) { + this.path = path; + this.dir = dir; + hash = calcFileHash(dir.resolve(path).toString()); + } + + /** + * Calculates SHA-1 hash of the file. + * @param filePath path to the file to calculate hash. + * @return SHA-1 hash of the provided file. + */ + @Nonnull + public static String calcFileHash(@Nonnull String filePath) { + DigestInputStream stream; + byte[] hash = null; + try (FileInputStream fin = new FileInputStream(filePath)) { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-1"); + stream = new DigestInputStream(fin, messageDigest); + while (stream.read() != -1) {} + hash = messageDigest.digest(); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } catch (NoSuchAlgorithmException e) {} + return new BigInteger(1, hash).toString(); + } + + /** + * Gets hash of the file. + * @return the hash of the file. + */ + @Nonnull + public String getHash() { + return hash; + } + + /** + * Gets the path to the file. + * @return the path to the file. + */ + @Nonnull + public Path getName() { + return path; + } + + /** + * Returns the directory to which the file belongs. + * @return dir + */ + @Nonnull + public Path getDir() { + return dir; + } + + /** + * Compares this object with another HashedFile. Two HashedFiles are considered equal + * if their hashes coincide. + * @param obj another object to compare this one with. + * @return true if the given object is an equal HashedFile, false if it is not equal + * or is not a HashedFile object. + */ + @Override + public boolean equals(@Nullable Object obj) { + return (obj != null) && (obj instanceof TrackedFile) && + hash.equals(((TrackedFile) obj).getHash()); + } + + /** + * Gets the complete path to the file. Complete path consists of the directory path + * and the file path. + * @return the path to the file. + */ + @Nonnull + public Path getLocation() { + return dir.resolve(path); + } + + /** + * Gets the name of the file. + * @return String returned by toString method of path from dir. + */ + @Override + public String toString() { + return path.toString(); + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/IntersectedFolder.java b/src/main/java/ru/spbau/gusev/vcs/IntersectedFolder.java new file mode 100644 index 0000000..e88eb5c --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/IntersectedFolder.java @@ -0,0 +1,125 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +/** + * A folder that may intersect with other folders. Files present in several folders + * linking to one storage are only stored once. + */ +public class IntersectedFolder { + private final IntersectedFolderStorage storage; + private final Path listPath; + private final Map files = new HashMap<>(); + + /** + * Creates a IntersectedFolder with the given storage and path to files list. + * @param storage the storage where files should be kept. + * @param listPath the path to the file with a list of the folder content. + */ + IntersectedFolder(@Nonnull IntersectedFolderStorage storage, + @Nonnull Path listPath) { + this.storage = storage; + this.listPath = listPath; + if (Files.isRegularFile(listPath)) { + try { + for (String line: Files.readAllLines(listPath)) { + String[] parts = line.split(" "); + if (parts.length != 2) { + throw new IllegalStateException("Incorrect intersected " + + "folder files list format"); + } + Path name = Paths.get(parts[0]); + String hash = parts[1]; + files.put(name, storage.getFile(hash, name)); + } + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } catch (VCS.NoSuchFileException e) { + throw new IllegalStateException("Intersected folder file not found"); + } + } + } + + /** + * Adds a file to the directory. + * @param file the file to add. + */ + protected void add(@Nonnull TrackedFile file) { + files.put(file.getName(), storage.add(file)); + } + + /** + * Picks a file from the folder. + * @param name the path to the file in the working directory. + * @return a TrackedFile Object representing the required file. + */ + @Nullable + protected TrackedFile getFile(@Nonnull Path name) { + return files.get(name); + } + + /** + * Deletes a file from the folder. + * @param name the path to the file in the working directory. + * @throws VCS.NoSuchFileException if a file with the given name does not exist in + * the folder. + */ + protected void delete(@Nonnull Path name) throws VCS.NoSuchFileException { + storage.delete(files.get(name).getHash()); + files.remove(name); + } + + /** + * Writes the list of files to the disk. + */ + protected void writeList() { + try { + if (Files.exists(listPath)) { + Files.delete(listPath); + } + BufferedWriter listWriter = Files.newBufferedWriter(listPath, StandardOpenOption.WRITE, + StandardOpenOption.CREATE); + files.forEach((path, sharedHashedFile) -> { + try { + listWriter.write(path + " " + sharedHashedFile.getHash() + "\n"); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + }); + listWriter.close(); + storage.writeCounters(); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Creates a stream containing all files in the folder. + * @return a Stream object containing TrackedFile representations of all files in + * the folder. + */ + @Nonnull + protected Stream getFiles() { + return files.values().stream(); + } + + /** + * Checks if a file with given name exists in the folder. + * @param fileName the name of the file to check. + * @return true if a file with the given name exists in the folder, false if it + * doesn't. + */ + protected boolean contains(@Nonnull Path fileName) { + return files.containsKey(fileName); + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/IntersectedFolderStorage.java b/src/main/java/ru/spbau/gusev/vcs/IntersectedFolderStorage.java new file mode 100644 index 0000000..1aec4a5 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/IntersectedFolderStorage.java @@ -0,0 +1,169 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.HashMap; +import java.util.Map; + +/** + * A storage for files from IntersectedFolder-s. Only a single copy of a file is + * stored regardless how many folders contain it. A file is deleted when no folders + * link to it anymore. + */ +public class IntersectedFolderStorage { + private final Path folder; + private final Path counterList; + private final Map counters = new HashMap<>(); + + /** + * Creates a storage object. + * @param folder the folder where files should be kept. + * @param counterList the path to a file with a list of the storage content. + */ + IntersectedFolderStorage(@Nonnull Path folder, @Nonnull Path counterList) { + this.folder = folder; + this.counterList = counterList; + if (Files.isRegularFile(counterList)) { + try { + for (String line: Files.readAllLines(counterList)) { + String[] parts = line.split(" "); + if (parts.length != 2) { + throw new IllegalStateException("Incorrect " + + "IntersectedFolderStorage file list format."); + } + String hash = parts[0]; + Integer counter = Integer.valueOf(parts[1]); + counters.put(hash, counter); + } + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + } + + /** + * Adds a file to the storage. The file is not copied if is already present in + * the storage. + * @param file the file to be added. + * @return a TrackedFile representation of the file in the storage. + */ + @Nonnull + protected TrackedFile add(@Nonnull TrackedFile file) { + Integer refsCounter = counters.getOrDefault(file.getHash(), 0); + if (refsCounter.equals(0)) { + Path newPath = folder.resolve(file.getHash()); + try { + Files.copy(file.getLocation(), newPath); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + counters.put(file.getHash(), ++refsCounter); + return new SharedHashedFile(file.getHash(), file.getName()); + } + + /** + * Gets a file from the storage. + * @param hash the hash of the file. + * @param name the path to th file in the working directory. + * @return a TrackedFile representation of the required file. + * @throws VCS.NoSuchFileException if a file with the given hash does not exist + * in the storage. + */ + @Nonnull + protected TrackedFile getFile(@Nonnull String hash, @Nonnull Path name) + throws VCS.NoSuchFileException { + if (!Files.notExists(folder.resolve(name))) { + throw new VCS.NoSuchFileException(); + } + return new SharedHashedFile(hash, name); + } + + /** + * Writes file link counters to the disk. + */ + protected void writeCounters() { + try { + if (Files.exists(counterList)) { + Files.delete(counterList); + } + BufferedWriter countersWriter = Files.newBufferedWriter(counterList, + StandardOpenOption.WRITE, StandardOpenOption.CREATE); + counters.forEach((hash, counter) -> { + try { + countersWriter.write(hash + ' ' + counter.toString() + '\n'); + } catch (IOException e){ + throw new VCS.FileSystemError(e.getMessage()); + } + }); + countersWriter.close(); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Deletes a link to the file with the given hash. If no links to this file rest, + * it is removed from the disk. + * @param hash the hash of the file to remove. + * @throws VCS.NoSuchFileException if a file with the given hash does not exist + * in the repository. + */ + protected void delete(@Nonnull String hash) throws VCS.NoSuchFileException { + Integer counter = counters.get(hash); + if (counter == null) { + throw new VCS.NoSuchFileException(); + } + + counter--; + if (counter.equals(0)) { + counters.remove(hash); + + Path filePath = folder.resolve(hash); + if (!Files.isRegularFile(filePath)) { + throw new VCS.NoSuchFileException(); + } + try { + Files.delete(filePath); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } else { + counters.put(hash, counter); + } + } + + private class SharedHashedFile implements TrackedFile { + private final String hash; + private final Path name; + + public SharedHashedFile(@Nonnull String hash, @Nonnull Path name) { + this.hash = hash; + this.name = name; + } + + @Nonnull + public String getHash() { + return hash; + } + + @Nonnull + public Path getName() { + return name; + } + + @Nonnull + public Path getLocation() { + return folder.resolve(hash); + } + + @Override + public boolean equals(Object o) { + return o instanceof TrackedFile && hash.equals(((TrackedFile) o).getHash()); + } + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/Merger.java b/src/main/java/ru/spbau/gusev/vcs/Merger.java new file mode 100644 index 0000000..6730d78 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/Merger.java @@ -0,0 +1,78 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * A module for merging commits. + */ +public abstract class Merger { + /** + * Merges the supplied branch into current. + * @param branchToMerge the branch to merge into current. + * @throws VCS.BadRepoException if the repository folder is corrupt. + * @throws VCS.BadPositionException if the current position is not a head of a + * branch. + */ + @Nonnull + protected static Commit merge(Repository repository, + @Nonnull Branch branchToMerge) throws + VCS.BadRepoException, VCS.BadPositionException { + Commit curCommit = repository.getCurrentCommit(); + Commit commitToMerge = branchToMerge.getHead(); + + List curCommitPedigree = curCommit.getPedigree(); + List commitToMergePedigree = commitToMerge.getPedigree(); + + int pos = 0; + while (pos < curCommitPedigree.size() && pos < commitToMergePedigree.size() && + curCommitPedigree.get(pos).equals(commitToMergePedigree.get(pos))) { + pos++; + } + Commit commonPredecessor = curCommitPedigree.get(pos - 1); + + Map sourceFiles = commonPredecessor.getFiles() + .collect(Collectors.toMap(TrackedFile::getName, file -> file)); + Map mergedFiles = commitToMerge.getFiles() + .collect(Collectors.toMap(TrackedFile::getName, file -> file)); + Map curFiles = curCommit.getFiles() + .collect(Collectors.toMap(TrackedFile::getName, file -> file)); + Map resFiles = new HashMap<>(); + + resFiles.putAll(sourceFiles); + curFiles.forEach((path, hashedFile) -> { + if (!sourceFiles.containsKey(path) || !hashedFile.equals(sourceFiles.get(path))) { + resFiles.put(path, hashedFile); + } + }); + + mergedFiles.forEach((path, hashedFile) -> { + if (!sourceFiles.containsKey(path) || !hashedFile.equals(sourceFiles.get(path))) { + resFiles.put(path, hashedFile); + } + }); + + sourceFiles.forEach((name, desc) -> { + if (!curFiles.containsKey(name) || !mergedFiles.containsKey(name)) { + resFiles.remove(name); + } + }); + + StagingZone stagingZone = repository.getStagingZone(); + stagingZone.wipe(); + resFiles.forEach((path, desc) -> stagingZone.add(desc)); + + Commit mergedCommit = Commit.create( + "Branch " + branchToMerge.getName() + " merged.", + repository); + WorkingDirectory workingDirectory = repository.getWorkingDirectory(); + curCommit.removeFrom(workingDirectory); + mergedCommit.checkout(workingDirectory, stagingZone); + repository.setCurrentCommit(mergedCommit); + return mergedCommit; + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/Repository.java b/src/main/java/ru/spbau/gusev/vcs/Repository.java new file mode 100644 index 0000000..f460f53 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/Repository.java @@ -0,0 +1,388 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.*; +import java.util.List; +import java.util.Scanner; +import java.util.stream.Collectors; + +/** + * A class representing a VCS repository. + */ +public class Repository { + protected static final String REPO_DIR_NAME = ".vcs"; + protected static final String BRANCHES_DIR_NAME = "branches"; + protected static final String COMMITS_DIR_NAME = "commits"; + protected static final String USERNAME_FILE = "user"; + protected static final String COMMITS_COUNTER_FILENAME = "commit"; + protected static final String POSITION_FILENAME = "position"; + protected static final String DEFAULT_BRANCH = "master"; + private static final String STAGE_DIR = "stage"; + private static final String STAGE_LIST = "stage_list"; + private static final String COMMITS_FILES_STORAGE = "commits_files"; + private static final String COMMITS_FILES_LIST = "commits_files_list"; + + private final StagingZone stage; + private final WorkingDirectory workingDirectory; + private final IntersectedFolderStorage commitStorage; + + private Repository() { + try { + stage = new StagingZone(Paths.get(REPO_DIR_NAME, STAGE_DIR), + Paths.get(REPO_DIR_NAME, STAGE_LIST)); + } catch (VCS.NoSuchFileException e) { + throw new IllegalStateException(e); + } + + workingDirectory = new WorkingDirectory(Paths.get(".")); + + commitStorage = new IntersectedFolderStorage( + Paths.get(REPO_DIR_NAME, COMMITS_FILES_STORAGE), + Paths.get(REPO_DIR_NAME, COMMITS_FILES_LIST)); + } + + /** + * Initialises a repository in the current directory. A folder with all the + * necessary information is created. + * @param author the first username for the created repository. + * @throws VCS.RepoAlreadyExistsException if a repository is already + * initialised in the current folder. + * @throws IllegalArgumentException if the author parameter is an empty string. + */ + protected static Repository create(@Nonnull String author) throws VCS.RepoAlreadyExistsException { + if (Files.exists(Paths.get(Repository.REPO_DIR_NAME), LinkOption.NOFOLLOW_LINKS)) { + throw new VCS.RepoAlreadyExistsException(); + } + if (author.isEmpty()) { + throw new IllegalArgumentException("Empty username."); + } + try { + Files.createDirectory(Paths.get(REPO_DIR_NAME)); + Files.createDirectory(Paths.get(REPO_DIR_NAME, COMMITS_FILES_STORAGE)); + Files.createFile(Paths.get(REPO_DIR_NAME, COMMITS_FILES_LIST)); + + //Setting username + Files.write(Paths.get(REPO_DIR_NAME, USERNAME_FILE), author.getBytes()); + + //Setting up commit counter + Files.write(Paths.get(REPO_DIR_NAME, COMMITS_COUNTER_FILENAME), + "0".getBytes()); + + //Creating stage directory + Files.createDirectory(Paths.get(REPO_DIR_NAME, STAGE_DIR)); + Files.createFile(Paths.get(REPO_DIR_NAME, STAGE_LIST)); + + Repository repo = new Repository(); + repo.getStagingZone().wipe(); + + //Setting up position tracking + Files.write(Paths.get(REPO_DIR_NAME, POSITION_FILENAME), + (DEFAULT_BRANCH + "\n-1").getBytes()); + + //Initialising master branch + Files.createDirectory(Paths.get(REPO_DIR_NAME, BRANCHES_DIR_NAME)); + Files.createFile(Paths.get(REPO_DIR_NAME, BRANCHES_DIR_NAME, DEFAULT_BRANCH)); + + //Preparing initial commit + Files.createDirectory(Paths.get(REPO_DIR_NAME, COMMITS_DIR_NAME)); + try { + Commit.create("Initial commit.", repo); + } catch (VCS.BadPositionException e) { + throw new VCS.BadRepoException(); + } + return repo; + } catch (IOException | VCS.BadRepoException e) { + try { + HashedDirectory.deleteDir(Paths.get(REPO_DIR_NAME)); + } catch (IOException e1) { + e.addSuppressed(e1); + } + throw new VCS.FileSystemError(e); + } + } + + /** + * Makes a Repository instance for a repository already existing in the + * current folder. + * @return Repository instance for a repository already existing in the + * current folder. + */ + protected static Repository getExisting() { + if (!Files.isDirectory(Paths.get(REPO_DIR_NAME))) { + throw new IllegalStateException("No repository found."); + } + return new Repository(); + } + + /** + * Get the number of commits in the repository. + * @return the number of commits in the repository. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + protected int getCommitsNumber() throws VCS.BadRepoException { + List lines; + int commitsNumber; + try { + lines = Files.readAllLines(Paths.get(REPO_DIR_NAME, COMMITS_COUNTER_FILENAME)); + } catch (IOException e) { + throw new VCS.FileSystemError(); + } + if (lines.size() != 1) { + throw new VCS.BadRepoException(); + } + try { + commitsNumber = Integer.valueOf(lines.get(0)); + } catch (NumberFormatException e) { + throw new VCS.BadRepoException(); + } + return commitsNumber; + } + + /** + * Gets current branch. + * @return a Branch object representing the current branch. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + @Nonnull + protected Branch getCurBranch() throws VCS.BadRepoException { + String curBranchName; + Path posFilePath = Paths.get(REPO_DIR_NAME, POSITION_FILENAME); + if (Files.notExists(posFilePath)) { + throw new VCS.BadRepoException(); + } + + try (Scanner scanner = new Scanner(posFilePath)) { + curBranchName = scanner.next(); + } catch (IOException e) { + throw new VCS.FileSystemError(); + } + try { + return Branch.getByName(curBranchName, this); + } catch (VCS.NoSuchBranchException e) { + throw new VCS.BadRepoException(); + } + } + + /** + * Gets the name of the current user. + * @return username. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + @Nonnull + protected String getUserName() throws VCS.BadRepoException { + String username; + try { + if (Files.notExists(Paths.get(REPO_DIR_NAME, USERNAME_FILE))) { + throw new VCS.BadRepoException(); + } + BufferedReader reader = new BufferedReader( + new FileReader(Paths.get(REPO_DIR_NAME, USERNAME_FILE).toString())); + username = reader.readLine(); + reader.close(); + } catch (IOException e) { + throw new VCS.FileSystemError(); + } + return username; + } + + /** + * Sets updates current user's name. + * @param name new user name. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + protected void setUserName(@Nonnull String name) throws VCS.BadRepoException { + if (!Files.exists(Paths.get(REPO_DIR_NAME, USERNAME_FILE))) { + throw new VCS.BadRepoException(); + } + if (name.isEmpty()) { + throw new IllegalArgumentException("Empty username."); + } + + try (FileWriter writer = new FileWriter( + Paths.get(REPO_DIR_NAME, USERNAME_FILE).toString())) { + writer.write(name); + } catch (IOException e){ + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Gets the number of the current head. + * @return the number of the currently heading commit. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + @Nonnull + protected Integer getCurrentCommitNumber() throws VCS.BadRepoException { + int curCommit; + Path posFilePath = Paths.get(REPO_DIR_NAME, POSITION_FILENAME); + if (Files.notExists(posFilePath)) { + throw new VCS.BadRepoException(); + } + + try (Scanner scanner = new Scanner(posFilePath)) { + scanner.next(); + curCommit = scanner.nextInt(); + } catch (IOException e) { + throw new VCS.FileSystemError(); + } + return curCommit; + } + + /** + * Gets current commit. + * @return a Commit object representing the currently heading commit. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + @Nonnull + protected Commit getCurrentCommit() throws VCS.BadRepoException { + try { + return Commit.read(getCurrentCommitNumber() ,this); + } catch (VCS.NoSuchCommitException e){ + throw new VCS.BadRepoException("Current commit not found."); + } + } + + /** + * Updates the current branch. + * @param branch new current branch. + */ + protected void setCurrentBranch(@Nonnull Branch branch) { + Path posPath = Paths.get(REPO_DIR_NAME, POSITION_FILENAME); + try { + List pos = Files.readAllLines(posPath); + Files.write(posPath, (branch.getName() + '\n' + pos.get(1)).getBytes()); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Updates current commit. + * @param commit the new current commit. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + protected void setCurrentCommit(@Nonnull Commit commit) throws VCS.BadRepoException { + Path posPath = Paths.get(REPO_DIR_NAME, POSITION_FILENAME); + if (Files.notExists(posPath)) { + throw new VCS.BadRepoException("Position file not found."); + } + try { + Files.write(posPath, (commit.getBranch().getName() + '\n' + + commit.getNumber().toString()).getBytes()); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Sets the commit counter into given value. + * @param val new commit counter value. + * @throws VCS.BadRepoException if the repository folder is corrupt. + */ + protected void updateCommitCounter(@Nonnull Integer val) throws VCS.BadRepoException { + Path counterPath = Paths.get(REPO_DIR_NAME, COMMITS_COUNTER_FILENAME); + if (Files.notExists(counterPath)) { + throw new VCS.BadRepoException("Commits counter file not found."); + } + try { + Files.write(counterPath, val.toString().getBytes()); + } catch (IOException e){ + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Checks out a supplied commit. The working directory is returned to the + * condition of that commit. + * @param commitID the number of the commit too checkout. + * @throws VCS.BadRepoException if the repository folder is corrupt. + * @throws VCS.NoSuchCommitException if the requested commit does not exist. + */ + protected void checkoutCommit(@Nonnull Integer commitID) throws + VCS.BadRepoException, VCS.NoSuchCommitException { + Commit curCommit = getCurrentCommit(); + curCommit.removeFrom(getWorkingDirectory()); + getStagingZone().wipe(); + Commit newCommit = Commit.read(commitID, this); + newCommit.checkout(getWorkingDirectory(), getStagingZone()); + setCurrentCommit(newCommit); + } + + /** + * Gets the staging zone of the repository. + * @return a StagingZone object for this repository. + */ + @Nonnull + protected StagingZone getStagingZone() throws VCS.BadRepoException { + return stage; + } + + /** + * Gets the WorkingDirectory representation of this repository's working directory. + * @return a WorkingDirectory object referring to the repository's working + * directory. + */ + @Nonnull + protected WorkingDirectory getWorkingDirectory() { + return workingDirectory; + } + + /** + * Lists all the branches in the repository. + * @return a list containing all the branches in the repository. + * @throws VCS.BadRepoException if the repository is corrupt. + */ + @Nonnull + protected List getBranches() throws VCS.BadRepoException { + Path branchesDir = Paths.get(REPO_DIR_NAME, BRANCHES_DIR_NAME); + if (!Files.isDirectory(branchesDir)) { + throw new VCS.BadRepoException("Branches dir not found"); + } + + try { + return Files.list(branchesDir) + .map(line -> { + try { + String branchName = branchesDir.relativize(line).toString(); + return Branch.getByName(branchName, this); + } catch (VCS.NoSuchBranchException e) { + throw new Error(); + } + }) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } catch (Error e) { + throw new VCS.BadRepoException("Error reading branch."); + } + } + + /** + * Gets a IntersectedFolderStorage with files form the commits. + * @return + */ + @Nonnull + protected IntersectedFolderStorage getCommitStorage() { + return commitStorage; + } + + /** + * Gets a list of all existing branch names. + * @return a list containing names of all branches. + */ + @Nonnull + protected List getBranchNames() { + try { + return Files.list(Paths.get(REPO_DIR_NAME, BRANCHES_DIR_NAME)) + .map(path -> path.getName(path.getNameCount() - 1)) + .map(Object::toString) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/StagingZone.java b/src/main/java/ru/spbau/gusev/vcs/StagingZone.java new file mode 100644 index 0000000..ca3bc52 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/StagingZone.java @@ -0,0 +1,91 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +/** + * A class representing the Staging zone. The function of the staging zone is + * determining which files should be included in the next commit. + */ +public class StagingZone { + private final HashedDirectory stageHashDir; + + /** + * Creates a StagingZone Object with given stage path and files list path. + * @param stagePath the path to the staging directory. + * @param listPath the path to the list of staged files. + */ + protected StagingZone(Path stagePath, Path listPath) throws + VCS.NoSuchFileException { + if (!Files.isDirectory(stagePath)) { + throw new VCS.NoSuchFileException("Stage directory not found."); + } + if (!Files.isRegularFile(listPath)) { + throw new VCS.NoSuchFileException("Stage list not found."); + } + stageHashDir = new HashedDirectory(stagePath, listPath); + } + + /** + * Adds a file to the staging directory, including it into the next commit. + * @param file the file to add represented by a HashedFile object. + */ + protected void add(@Nonnull TrackedFile file) { + stageHashDir.add(file); + stageHashDir.writeHashes(); + } + + /** + * Removes all staged files from the staging zone. + */ + protected void wipe() { + stageHashDir.clear(); + stageHashDir.writeHashes(); + } + + /** + * Creates a stream containing all files from staging directory as HashedFile objects. + * @return a stream with all staged files. + */ + protected Stream getFiles() { + return stageHashDir.getFiles(); + } + + /** + * Removes the file pointed by given path from staging zone. + * @param file the path pointing to the staged file to delete. + * @return true if a file with the given path was removed from the staging zone, + * false if it had not been staged. + */ + protected boolean removeFile(@Nonnull Path file) { + try { + stageHashDir.deleteFile(file); + stageHashDir.writeHashes(); + } catch (VCS.NoSuchFileException e) { + return false; + } + return true; + } + + /** + * Checks if a file with given path is in the staging zone. + * @param filePath the path to check. + * @return true if a file with given path exists in the staging zone, false otherwise. + */ + protected boolean contains(@Nonnull Path filePath) { + return stageHashDir.contains(filePath); + } + + /** + * Gets a HashedFile representation of a file from the staging zone by its path. + * @param filePath the path to the file. + * @return a HashedFile representation of the file or null if the file doesn't exist. + */ + @Nullable + protected HashedFile getHashedFile(@Nullable Path filePath) { + return stageHashDir.getHashedFile(filePath); + } +} diff --git a/src/main/java/ru/spbau/gusev/vcs/TrackedFile.java b/src/main/java/ru/spbau/gusev/vcs/TrackedFile.java new file mode 100644 index 0000000..c605a28 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/TrackedFile.java @@ -0,0 +1,31 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import java.nio.file.Path; + +/** + * An interface representing a copy of a file in the repository. + */ +public interface TrackedFile { + /** + * Gets the hash of the file. + * @return the hash of the file. + */ + @Nonnull + String getHash(); + + /** + * Gets the path to the file in the working directory as at the moment of the file + * addition to the repository. + * @return the path to the file from the working directory. + */ + @Nonnull + Path getName(); + + /** + * Gets a path to the current real location of the file. + * @return a path leading to a copy of the file on the disk. + */ + @Nonnull + Path getLocation(); +} diff --git a/src/main/java/ru/spbau/gusev/vcs/VCS.java b/src/main/java/ru/spbau/gusev/vcs/VCS.java new file mode 100644 index 0000000..4be40be --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/VCS.java @@ -0,0 +1,422 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import java.nio.file.*; +import java.util.*; +import java.util.stream.Collectors; + +/** + * An class containing VCS public API methods. + */ +public class VCS { + /** + * Initialises a repository in the current directory. A folder with all the + * necessary information is created. + * @param author the first username for the created repo + * @throws RepoAlreadyExistsException if a repo is already initialised in current folder + */ + public static void createRepo(@Nonnull String author) throws RepoAlreadyExistsException { + Repository.create(author); + } + + /** + * Sets the current username in the repository in the given value + * @param name new username + * @throws BadRepoException if the repository information folder is + * not in a correct condition. + */ + public static void setUserName(@Nonnull String name) throws BadRepoException { + Repository.getExisting().setUserName(name); + } + + /** + * Creates a new commit with a supplied message including all the changes + * that have been added to the stage. + * @param message the message that will be added to commit + * @throws BadRepoException if the repository information folder is + * not in a correct condition. + * @throws BadPositionException if the current commit is not the head of the + * current branch. + */ + public static void commit(@Nonnull String message) throws BadRepoException, + BadPositionException, NothingToCommitException { + Repository repository = Repository.getExisting(); + if (getStaged().isEmpty()) { + throw new NothingToCommitException(); + } + repository.setCurrentCommit(Commit.create(message, repository)); + } + + /** + * Adds a file to the stage zone. Files form the stage zone are included in the + * next commit. + * @param path the path to the file to be added. + * @throws BadRepoException if the repository information folder is + * not in a correct condition. + * @throws NoSuchFileException if the given path does not lead to a file. + */ + public static void addFile(@Nonnull String path) throws BadRepoException, NoSuchFileException { + Repository repository = Repository.getExisting(); + HashedFile file = repository.getWorkingDirectory().getHashedFile(path); + repository.getStagingZone().add(file); + } + + /** + * Creates a new branch with the given name. + * @param branchName the name for the new branch. + * @throws BadRepoException if the repository information folder is + * not in a correct condition. + * @throws BranchAlreadyExistsException if a branch with this name already exists. + */ + public static void createBranch(@Nonnull String branchName) + throws BadRepoException, BranchAlreadyExistsException { + Repository repository = Repository.getExisting(); + repository.setCurrentBranch(Branch.create(branchName, repository)); + } + + /** + * Deletes the branch with the given name. + * @param branchName - the name of the branch to be deleted. + * @throws BadRepoException if the repository information folder is + * not in a correct condition. + * @throws NoSuchBranchException if a branch with the given name does not exist. + */ + public static void deleteBranch(@Nonnull String branchName) throws BadRepoException, + NoSuchBranchException, BadPositionException { + Repository repository = Repository.getExisting(); + Branch branchToDelete = Branch.getByName(branchName, repository); + if (!branchToDelete.getName().equals(Repository.DEFAULT_BRANCH) && + branchToDelete.equals(repository.getCurBranch())) { + branchToDelete.delete(); + } + } + + /** + * Prepares a list with information about all the commits in a specified branch. + * @return a list of CommitDescription objects representing commits of the specified branch. + * @throws BadRepoException if the repository information folder is + * not in a correct condition. + * @throws NoSuchBranchException if a branch with the given name does not exist. + */ + @Nonnull + public static List getLog() throws BadRepoException, + NoSuchBranchException { + Branch curBranch = Repository.getExisting().getCurBranch(); + return curBranch.getLog(); + } + + /** + * Returns the directory to the condition in which it was at the moment of the + * specified commit. + * @param commitID the number of the required commit. + * @throws BadRepoException if the repository information folder is + * not in a correct condition. + * @throws NoSuchCommitException if a commit with the given number does + * not exist. + */ + public static void checkoutCommit(int commitID) throws BadRepoException, + NoSuchCommitException { + Repository repository = Repository.getExisting(); + repository.checkoutCommit(commitID); + } + + /** + * Returns the working directory to the condition in which in was at the moment + * of the last commit of a branch. + * @param branchName the name of the required branch + * @throws BadRepoException if the repository information folder is + * not in a correct condition. + * @throws NoSuchBranchException if a branch with given name does not exist. + */ + public static void checkoutBranch(@Nonnull String branchName) throws BadRepoException, + NoSuchBranchException { + Repository repository = Repository.getExisting(); + Branch newBranch = Branch.getByName(branchName, repository); + try { + repository.checkoutCommit(newBranch.getHeadNumber()); + repository.setCurrentBranch(newBranch); + } catch (NoSuchCommitException e) { + throw new BadRepoException("Branch's head commit not found."); + } + } + + /** + * Merges the specified branch into current. The changes if the merged branch + * have higher priority than in the current. + * @param branchName the name of the branch to merge + * @throws BadPositionException if the current position is not a branch head. + * @throws BadRepoException if the repository information folder is + * not in a correct condition. + * @throws NoSuchBranchException if a branch with the given name does not exist. + */ + public static void merge(@Nonnull String branchName) throws BadPositionException, + BadRepoException, NoSuchBranchException { + Repository repo = Repository.getExisting(); + Merger.merge(repo, Branch.getByName(branchName, repo)); + } + + /** + * Removes file with given name from the working directory and staging zone. + * @param filename name of the file to remove. + * @throws NoSuchFileException if a file with the given name does not exist + * @throws BadRepoException if the repository folder is corrupt. + */ + public static void remove(@Nonnull String filename) throws NoSuchFileException, + BadRepoException { + Path filePath = Paths.get(filename); + Repository repository = Repository.getExisting(); + + if (!repository.getStagingZone().removeFile(filePath)) { + throw new NoSuchFileException(); + } + repository.getWorkingDirectory().delete(filePath); + } + + /** + * Returns the file with given name to its stage in the current commit. + * @param filename the name of file to reset. + */ + public static void reset(@Nonnull String filename) throws BadRepoException, + NoSuchFileException { + Repository repository = Repository.getExisting(); + Path filePath = Paths.get(filename); + repository.getCurrentCommit().resetFile(filePath, + repository.getWorkingDirectory(), repository.getStagingZone()); + } + + /** + * Removes all the files that have not been added to the repository from + * the working directory. + */ + public static void clean() throws BadRepoException { + Repository repository = Repository.getExisting(); + final StagingZone stagingZone = repository.getStagingZone(); + repository.getWorkingDirectory().deleteIf(file -> !stagingZone.contains(file)); + } + + /** + * Lists all staged files that are not present in the last commit in their staged + * condition. + * @return a list containing names of all staged files. + * @throws VCS.BadRepoException if the repository data folder is corrupt. + */ + @Nonnull + public static List getStaged() throws BadRepoException { + Repository repository = Repository.getExisting(); + Commit currentCommit = repository.getCurrentCommit(); + return repository.getStagingZone().getFiles() + .filter(file -> { + TrackedFile fileInCommit = currentCommit.getFile(file.getName()); + return (fileInCommit == null || !file.equals(fileInCommit)); + }) + .map(HashedFile::toString) + .collect(Collectors.toList()); + } + + /** + * Lists all the files that have been added to the repository but are in a different + * condition at the moment of method call. + * @return a list containing names of all changed files. + */ + @Nonnull + public static List getChanged() throws BadRepoException { + Repository repository = Repository.getExisting(); + final StagingZone stagingZone = repository.getStagingZone(); + return repository.getWorkingDirectory().getFiles() + .filter(file -> { + HashedFile stagedFile = stagingZone.getHashedFile(file.getName()); + return stagedFile != null && !file.equals(stagedFile); + }) + .map(HashedFile::toString) + .collect(Collectors.toList()); + } + + /** + * Lists all the files in the working directory that have not been added to the + * repository. + * @return a List containing names of all files that are not in the repository. + */ + @Nonnull + public static List getCreated() throws BadRepoException { + Repository repository = Repository.getExisting(); + final StagingZone stagingZone = repository.getStagingZone(); + return repository.getWorkingDirectory().getFiles() + .filter(file -> !stagingZone.contains(file.getName())) + .map(HashedFile::toString) + .collect(Collectors.toList()); + } + + /** + * Lists files that have been removed since the creation of the current commit. + * @return a list with all removed files. + */ + @Nonnull + public static List getRemoved() throws BadRepoException { + Repository repository = Repository.getExisting(); + return repository.getCurrentCommit().getRemovedFiles( + repository.getStagingZone()); + } + + /** + * Gets name of current branch. + * @return the name of the current branch. + * @throws BadRepoException if the repository folder is corrupt. + */ + @Nonnull + public static String getCurBranch() throws BadRepoException { + return Repository.getExisting().getCurBranch().getName(); + } + + /** + * Gets a list of all existing branch names. + * @return a list containing names of all branches. + */ + @Nonnull + public static List getBranchNames() { + return Repository.getExisting().getBranchNames(); + } + + /** + * A class representing a commit object for interface. + */ + public static class CommitDescription { + private int number; + private String branch; + private String author; + private String message; + private Calendar time; + + CommitDescription(@Nonnull Commit commit) { + this.number = commit.getNumber(); + this.branch = commit.getBranch().getName(); + this.author = commit.getAuthor(); + this.message = commit.getMessage(); + Calendar.Builder builder = new Calendar.Builder(); + builder.setInstant(commit.getCreationTime()); + time = builder.build(); + } + + /** + * Get the commit number. + * @return commit number. + */ + public int getNumber() { + return number; + } + + /** + * Get the commit's branch + * @return the branch that the commit belongs to. + */ + @Nonnull + public String getBranch() { + return branch; + } + + /** + * Gets author of the commit. + * @return the author of this commit. + */ + @Nonnull + public String getAuthor() { + return author; + } + + /** + * Gets the message of the commit. + * @return the commit message + */ + @Nonnull + public String getMessage() { + return message; + } + + /** + * Gets time of the commit creation. + * @return a Calendar object representing the commit creation time. + */ + @Nonnull + public Calendar getTime() { + return time; + } + } + + /** + * An exception thrown in case of an attempt to create a repository where + * it already exists. + */ + public static class RepoAlreadyExistsException extends Exception{} + + /** + * An exception thrown if a branch required to create already exists. + */ + public static class BranchAlreadyExistsException extends Exception{} + + /** + * An exception thrown if the repository service folder is damaged. + */ + public static class BadRepoException extends Exception { + BadRepoException() {} + + BadRepoException(String message) { + super(message); + } + } + + /** + * An error thrown if the filesystem throws an IOException. + */ + public static class FileSystemError extends Error { + public FileSystemError() { + } + + public FileSystemError(String message) { + super(message); + } + + public FileSystemError(Throwable cause) { + super(cause); + } + } + + /** + * An exception thrown if the specified file does not exist. + */ + public static class NoSuchFileException extends Exception { + public NoSuchFileException() { + } + + public NoSuchFileException(String message) { + super(message); + } + } + + /** + * An exception thrown if the specified branch does not exist. + */ + public static class NoSuchBranchException extends Exception{} + + /** + * An exception thrown if the specified commit does not exist. + */ + public static class NoSuchCommitException extends Exception{} + + /** + * An exception thrown if an operation is impossible because of the head + * position. + */ + public static class BadPositionException extends Exception { + public BadPositionException() { + } + + public BadPositionException(String message) { + super(message); + } + } + + /** + * An exception thrown in case of an attempt to make a commit with no changes since + * the previous one. + */ + public static class NothingToCommitException extends Exception{}; +} diff --git a/src/main/java/ru/spbau/gusev/vcs/WorkingDirectory.java b/src/main/java/ru/spbau/gusev/vcs/WorkingDirectory.java new file mode 100644 index 0000000..e730dc7 --- /dev/null +++ b/src/main/java/ru/spbau/gusev/vcs/WorkingDirectory.java @@ -0,0 +1,142 @@ +package ru.spbau.gusev.vcs; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.nio.file.*; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public class WorkingDirectory { + private final Path workingDir; + private final List ignoredPaths; + private final static String IGNORE_LIST_FILENAME = ".ignore"; + + WorkingDirectory(@Nonnull Path dir) { + workingDir = dir; + + // Files with paths beginning with the paths listed in the .ignore file are + // not tracked. + ignoredPaths = new ArrayList<>(); + ignoredPaths.add(workingDir.resolve(Repository.REPO_DIR_NAME)); + Path ignoredListPath = workingDir.resolve(IGNORE_LIST_FILENAME); + ignoredPaths.add(ignoredListPath); + if (Files.exists(ignoredListPath)) { + try { + for (String line: Files.readAllLines(ignoredListPath)) { + try { + ignoredPaths.add(workingDir.resolve(line)); + } catch (InvalidPathException e) { + continue; + } + } + } catch (IOException e) { + throw new VCS.FileSystemError(); + } + } + } + + /** + * Copies the given TrackedFile to the working directory. + * @param file the file to copy. + */ + protected void add(@Nonnull TrackedFile file) { + try { + Files.createDirectories(workingDir.resolve(file.getName()). + toAbsolutePath().getParent()); + Files.copy(file.getLocation(), workingDir.resolve(file.getName()), + StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Creates a HashedFile representation of a file in the working directory. + * @param fileName the name of the file. + * @return a HashedFile object pointing to the given file. + * @throws VCS.NoSuchFileException if no file with the given name exists. + */ + @Nonnull + protected HashedFile getHashedFile(@Nonnull String fileName) + throws VCS.NoSuchFileException { + Path filePath = Paths.get(fileName); + if (Files.notExists(workingDir.resolve(filePath))) { + throw new VCS.NoSuchFileException(); + } + + return new HashedFile(filePath, workingDir); + } + + /** + * Deletes the file by the given path from the working directory. + * @param path the path to the file to delete. + * @return true if a file with given path was deleted, false if it didn't exist. + */ + protected boolean delete(@Nonnull Path path) { + path = workingDir.resolve(path); + if (!Files.exists(path)) { + return false; + } + + try { + if (Files.isRegularFile(path)) { + Files.delete(path); + } else { + HashedDirectory.deleteDir(path); + } + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + return true; + } + + /** + * Deletes files that satisfy the given predicate. + * @param condition the predicate that returns true if a file should be deleted. + */ + protected void deleteIf(@Nonnull Predicate condition) { + try { + Files.walk(workingDir) + .filter(Files::isRegularFile) + .filter(this::isNotIgnored) + .filter(path -> condition.test(workingDir.relativize(path))) + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + }); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + /** + * Creates a Stream of files in the directory. + * @return a Stream of files in the directory. + */ + @Nonnull + protected Stream getFiles() { + try { + return Files.walk(workingDir) + .filter(Files::isRegularFile) + .filter(this::isNotIgnored) + .map(path -> new HashedFile(workingDir.relativize(path), + workingDir)); + } catch (IOException e) { + throw new VCS.FileSystemError(e.getMessage()); + } + } + + private boolean isNotIgnored(@Nonnull Path path) { + for (Path ignored: ignoredPaths) { + if (path.startsWith(ignored)) { + return false; + } + } + return true; + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/BranchTest.java b/src/test/java/ru/spbau/gusev/vcs/BranchTest.java new file mode 100644 index 0000000..be7a17d --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/BranchTest.java @@ -0,0 +1,82 @@ +package ru.spbau.gusev.vcs; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +public class BranchTest { + @Test + public void creationDeletionTest() throws VCS.RepoAlreadyExistsException, IOException, + VCS.BranchAlreadyExistsException, VCS.BadRepoException, + VCS.NoSuchBranchException, VCS.BadPositionException { + final String BRANCH_NAME = "br"; + + try (RepoDir repo = new RepoDir()) { + Branch created = Branch.create(BRANCH_NAME, + Mockito.mock(Repository.class)); + Path branchDescPath = Paths.get(Repository.REPO_DIR_NAME, + Repository.BRANCHES_DIR_NAME, BRANCH_NAME); + Assert.assertTrue("Branch creation failure", + Files.exists(branchDescPath)); + + Files.write(Paths.get(RepoDir.ROOT, RepoDir.POSITION), + (RepoDir.MASTER + "\n0").getBytes()); + created.delete(); + Assert.assertTrue("Branch deletion failure", + Files.notExists(branchDescPath)); + } + } + + @Test + public void commitAdditionTest() throws VCS.RepoAlreadyExistsException, + VCS.BadPositionException, VCS.BadRepoException, IOException, + VCS.NoSuchBranchException, VCS.NoSuchCommitException { + try (RepoDir repo = new RepoDir()) { + Repository repository = Mockito.mock(Repository.class); + + Branch masterBranch = Branch.getByName(Repository.DEFAULT_BRANCH, + repository); + Assert.assertEquals(Repository.DEFAULT_BRANCH, masterBranch.getName()); + repo.commit(1, RepoDir.MASTER, new ArrayList<>(), + 0, "msg", 0); + Commit commit = Commit.read(1, repository); + masterBranch.addCommit(commit); + List masterCommits = Files.readAllLines( + Paths.get(Repository.REPO_DIR_NAME, + Repository.BRANCHES_DIR_NAME, masterBranch.getName())); + Assert.assertEquals(String.valueOf(1), + masterCommits.get(masterCommits.size() - 1)); + + Assert.assertEquals(commit, masterBranch.getHead()); + } + } + + @Test + public void logTest() throws IOException, VCS.RepoAlreadyExistsException, + VCS.BadPositionException, VCS.BadRepoException, VCS.NoSuchBranchException { + final String COMMIT_MESSAGE = "msg"; + + try (RepoDir repo = new RepoDir()) { + long time = System.currentTimeMillis(); + repo.commit(1, RepoDir.MASTER, new ArrayList<>(), time, + COMMIT_MESSAGE, 0); + Branch branch = Branch.getByName(RepoDir.MASTER, + Mockito.mock(Repository.class)); + List log = branch.getLog(); + VCS.CommitDescription commitDescription = log.get(0); + + Assert.assertEquals(1, commitDescription.getNumber()); + Assert.assertEquals(RepoDir.USERNAME, commitDescription.getAuthor()); + Assert.assertEquals(RepoDir.MASTER, commitDescription.getBranch()); + Assert.assertEquals(COMMIT_MESSAGE, commitDescription.getMessage()); + Assert.assertEquals(time, commitDescription.getTime().getTimeInMillis()); + } + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/CommitTest.java b/src/test/java/ru/spbau/gusev/vcs/CommitTest.java new file mode 100644 index 0000000..96c116c --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/CommitTest.java @@ -0,0 +1,100 @@ +package ru.spbau.gusev.vcs; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.Scanner; +import java.util.stream.Stream; + +public class CommitTest { + @Test + public void creationTest() throws IOException, VCS.RepoAlreadyExistsException, + VCS.NoSuchFileException, VCS.NoSuchBranchException, VCS.BadRepoException, + VCS.BadPositionException { + final String TEST_CONTENT = "foo"; + final String TEST_FILE_NAME = "foo"; + final String MESSAGE = "bar"; + final List EXPECTED_CONTENT = + Collections.singletonList(TEST_CONTENT); + + try (RepoDir repo = new RepoDir()) { + Path filePath = Paths.get(RepoDir.ROOT, RepoDir.STAGE, + TEST_FILE_NAME); + Files.write(filePath, TEST_CONTENT.getBytes()); + String fileHash = HashedFile.calcFileHash(filePath.toString()); + Files.write(Paths.get(RepoDir.ROOT, RepoDir.STAGE_LIST), + (TEST_FILE_NAME + " " + fileHash).getBytes()); + + //Mocking all other classes objects to be used in the test + Branch branch = Mockito.mock(Branch.class); + Mockito.when(branch.getName()).thenReturn(RepoDir.MASTER); + + StagingZone stagingZone = Mockito.mock(StagingZone.class); + Mockito.when(stagingZone.getFiles()).thenReturn(Stream.of( + new HashedFile(Paths.get(TEST_FILE_NAME), + Paths.get(RepoDir.ROOT, RepoDir.STAGE)))); + + IntersectedFolderStorage storage = + Mockito.mock(IntersectedFolderStorage.class); + Mockito.when(storage.add(Mockito.any())).thenReturn(new TrackedFile() { + private Path location = Paths.get(RepoDir.ROOT, + RepoDir.COMMITS_FILES, fileHash); + + { + Files.write(location, TEST_CONTENT.getBytes()); + } + + @Nonnull + @Override + public String getHash() { + return fileHash; + } + + @Nonnull + @Override + public Path getName() { + return Paths.get(TEST_FILE_NAME); + } + + @Nonnull + @Override + public Path getLocation() { + return location; + } + }); + + Repository repository = Mockito.mock(Repository.class); + Mockito.when(repository.getCurBranch()).thenReturn(branch); + Mockito.when(repository.getCommitsNumber()).thenReturn(1); + Mockito.when(repository.getStagingZone()).thenReturn(stagingZone); + Mockito.when(repository.getCommitStorage()).thenReturn(storage); + Mockito.when(repository.getUserName()).thenReturn(RepoDir.USERNAME); + + Commit commit = Commit.create(MESSAGE, repository); + Path commitDir = Paths.get(RepoDir.ROOT, RepoDir.COMMITS, + commit.getNumber().toString()); + + Scanner metadataScanner = new Scanner( + commitDir.resolve("metadata")); + metadataScanner.next(); + Assert.assertEquals(RepoDir.MASTER, + metadataScanner.next()); + Assert.assertEquals(RepoDir.USERNAME, metadataScanner.next()); + Assert.assertEquals(0, metadataScanner.nextInt()); + metadataScanner.nextLine(); + Assert.assertEquals(MESSAGE, metadataScanner.nextLine()); + + Assert.assertEquals(EXPECTED_CONTENT, + Files.readAllLines(Paths.get(RepoDir.ROOT, + RepoDir.COMMITS_FILES, fileHash))); + } + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/HashedDirectoryTest.java b/src/test/java/ru/spbau/gusev/vcs/HashedDirectoryTest.java new file mode 100644 index 0000000..f7aa966 --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/HashedDirectoryTest.java @@ -0,0 +1,136 @@ +package ru.spbau.gusev.vcs; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.stream.Stream; + +public class HashedDirectoryTest { + private static final Path TEST_DIR = Paths.get("test_dir"); + private static final Path TEST_LIST = Paths.get("test_list"); + private static final Path CUR_DIR = Paths.get("."); + private HashedDirectory hashedDirectory; + + @Before + public void prepare() throws IOException { + Files.createDirectory(TEST_DIR); + Files.createFile(TEST_LIST); + hashedDirectory = new HashedDirectory(TEST_DIR, TEST_LIST); + } + + @After + public void finish() throws IOException { + if (Files.isDirectory(TEST_DIR)) { + HashedDirectory.deleteDir(TEST_DIR); + } + if (Files.isRegularFile(TEST_LIST)) { + Files.delete(TEST_LIST); + } + hashedDirectory = null; + } + + @Test + public void addTest() throws IOException { + final Path filePath = Paths.get("foo"); + final String fileContent = "bar"; + + try { + Files.write(filePath, fileContent.getBytes()); + HashedFile hashedFile = new HashedFile(filePath, CUR_DIR); + hashedDirectory.add(hashedFile); + hashedDirectory.writeHashes(); + + String expectedHash = filePath.toString() + ' ' + + HashedFile.calcFileHash(filePath.toString()); + + Assert.assertTrue(Files.exists(TEST_DIR.resolve(filePath))); + Assert.assertEquals(Collections.singletonList(fileContent), + Files.readAllLines(TEST_DIR.resolve(filePath))); + Assert.assertEquals(Collections.singletonList(expectedHash), + Files.readAllLines(TEST_LIST)); + } finally { + Files.delete(filePath); + } + } + + @Test + public void clearTest() throws IOException, VCS.NoSuchFileException { + final Path filePath = Paths.get("foo"); + final String fileContent = "bar"; + + try { + Files.write(filePath, fileContent.getBytes()); + HashedFile hashedFile = new HashedFile(filePath, CUR_DIR); + hashedDirectory.add(hashedFile); + hashedDirectory.clear(); + hashedDirectory.writeHashes(); + + Assert.assertTrue(Files.notExists(TEST_DIR.resolve(filePath))); + Assert.assertEquals(Collections.emptyList(), + Files.readAllLines(TEST_LIST)); + } finally { + Files.delete(filePath); + } + } + + @Test + public void deleteTest() throws IOException, VCS.NoSuchFileException { + final Path filePath = Paths.get("foo"); + final String fileContent = "bar"; + + try { + Files.write(filePath, fileContent.getBytes()); + HashedFile hashedFile = new HashedFile(filePath, CUR_DIR); + hashedDirectory.add(hashedFile); + hashedDirectory.deleteFile(filePath); + hashedDirectory.writeHashes(); + + Assert.assertTrue(Files.notExists(TEST_DIR.resolve(filePath))); + Assert.assertEquals(Collections.emptyList(), + Files.readAllLines(TEST_LIST)); + } finally { + Files.delete(filePath); + } + } + + @Test + public void containsTest() throws IOException { + final Path filePath = Paths.get("foo"); + final String fileContent = "bar"; + + try { + Files.write(filePath, fileContent.getBytes()); + HashedFile hashedFile = new HashedFile(filePath, CUR_DIR); + hashedDirectory.add(hashedFile); + + Assert.assertTrue(hashedDirectory.contains(filePath)); + } finally { + Files.delete(filePath); + } + } + + @Test + public void getFilesTest() throws IOException { + final Path filePath = Paths.get("file"); + + try { + Files.createFile(filePath); + + hashedDirectory.add(new HashedFile(filePath, CUR_DIR)); + Stream files = hashedDirectory.getFiles() + .map(HashedFile::getName); + + Assert.assertTrue(files.allMatch(filePath::equals)); + Assert.assertEquals(1, hashedDirectory.getFiles().count()); + } finally { + Files.delete(filePath); + } + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/IntersectedFolderStorageTest.java b/src/test/java/ru/spbau/gusev/vcs/IntersectedFolderStorageTest.java new file mode 100644 index 0000000..5ded155 --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/IntersectedFolderStorageTest.java @@ -0,0 +1,134 @@ +package ru.spbau.gusev.vcs; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; + +public class IntersectedFolderStorageTest { + private static final Path TEST_FOLDER = Paths.get("test_folder"); + private static final Path TEST_LIST = Paths.get("test_list"); + private static final Path CUR_DIR = Paths.get("."); + private IntersectedFolderStorage storage; + + @Before + public void prepare() throws IOException { + Files.createDirectory(TEST_FOLDER); + Files.createFile(TEST_LIST); + storage = new IntersectedFolderStorage(TEST_FOLDER, TEST_LIST); + } + + @After + public void finish() throws IOException { + Files.delete(TEST_LIST); + HashedDirectory.deleteDir(TEST_FOLDER); + storage = null; + } + + @Test + public void addTest() throws IOException { + final Path filePath1 = Paths.get("file1"); + final Path filePath2 = Paths.get("file2"); + final Path filePath3 = Paths.get("file3"); + final String fileContent1 = "content1"; + final String fileContent2 = "content2"; + + try { + Files.write(filePath1, fileContent1.getBytes()); + Files.write(filePath2, fileContent2.getBytes()); + Files.write(filePath3, fileContent2.getBytes()); + + // Adding files + HashedFile hashedFile = new HashedFile(filePath1, CUR_DIR); + TrackedFile sharedHashedFile = storage.add(hashedFile); + storage.add(new HashedFile(filePath2, CUR_DIR)); + storage.add(new HashedFile(filePath3, CUR_DIR)); + storage.writeCounters(); + + // Checking that both files sre in the folder. + String hash1 = hashedFile.getHash(); + String hash2 = HashedFile.calcFileHash(filePath2.toString()); + Assert.assertTrue(Files.exists(TEST_FOLDER.resolve(hash1))); + Assert.assertEquals(Collections.singletonList(fileContent1), + Files.readAllLines(TEST_FOLDER.resolve(hash1))); + Assert.assertTrue(Files.exists(TEST_FOLDER.resolve(hash2))); + Assert.assertEquals(Collections.singletonList(fileContent2), + Files.readAllLines(TEST_FOLDER.resolve(hash2))); + + // Checking that both files are in the list. + Assert.assertEquals(2, Files.readAllLines(TEST_LIST).size()); + Assert.assertTrue(Files.readAllLines(TEST_LIST).contains(hash1 + " 1")); + Assert.assertTrue(Files.readAllLines(TEST_LIST).contains(hash2 + " 2")); + + //Checking that the returned SharedHashedFile is correct. + Assert.assertEquals(hash1, sharedHashedFile.getHash()); + Assert.assertEquals(filePath1, sharedHashedFile.getName()); + } finally { + Files.delete(filePath1); + Files.delete(filePath2); + Files.delete(filePath3); + } + } + + @Test + public void getTest() throws IOException, VCS.NoSuchFileException { + final Path filePath = Paths.get("file"); + final String fileContent = "content"; + + try { + Files.write(filePath, fileContent.getBytes()); + HashedFile hashedFile = new HashedFile(filePath, CUR_DIR); + storage.add(hashedFile); + + String hash = hashedFile.getHash(); + TrackedFile sharedHashedFile = storage.getFile(hash, filePath); + + Assert.assertEquals(hash, sharedHashedFile.getHash()); + Assert.assertEquals(filePath, sharedHashedFile.getName()); + } finally { + Files.delete(filePath); + } + } + + @Test + public void deleteTest() throws IOException, VCS.NoSuchFileException { + final Path filePath1 = Paths.get("file1"); + final Path filePath2 = Paths.get("file2"); + final Path filePath3 = Paths.get("file3"); + final String fileContent1 = "content1"; + final String fileContent2 = "content2"; + + try { + Files.write(filePath1, fileContent1.getBytes()); + Files.write(filePath2, fileContent2.getBytes()); + Files.write(filePath3, fileContent2.getBytes()); + + storage.add(new HashedFile(filePath1, CUR_DIR)); + storage.add(new HashedFile(filePath2, CUR_DIR)); + storage.add(new HashedFile(filePath3, CUR_DIR)); + + String hash1 = HashedFile.calcFileHash(filePath1.toString()); + String hash2 = HashedFile.calcFileHash(filePath2.toString()); + + storage.delete(hash1); + storage.delete(hash2); + storage.writeCounters(); + + Assert.assertTrue(Files.notExists(TEST_FOLDER.resolve(hash1))); + Assert.assertTrue(Files.exists(TEST_FOLDER.resolve(hash2))); + + Assert.assertEquals(Collections.singletonList(hash2 + " 1"), + Files.readAllLines(TEST_LIST)); + } finally { + Files.delete(filePath1); + Files.delete(filePath2); + Files.delete(filePath3); + } + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/IntersectedFolderTest.java b/src/test/java/ru/spbau/gusev/vcs/IntersectedFolderTest.java new file mode 100644 index 0000000..4b1092b --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/IntersectedFolderTest.java @@ -0,0 +1,94 @@ +package ru.spbau.gusev.vcs; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; + +public class IntersectedFolderTest { + private IntersectedFolderStorage mockedStorage; + private final Path folderList = Paths.get("folder_list"); + private final Path file = Paths.get("file"); + private final String fileContent = "ddd"; + private final Path curDir = Paths.get("."); + + @Before + public void makeMock() throws VCS.NoSuchFileException { + mockedStorage = Mockito.mock(IntersectedFolderStorage.class); + Mockito.when(mockedStorage.add(Mockito.any())).thenAnswer(inv -> + inv.getArgument(0)); + Mockito.when(mockedStorage.getFile(Mockito.anyString(), Mockito.any())) + .thenAnswer(inv -> { + String hash = inv.getArgument(0); + TrackedFile ret = Mockito.mock(TrackedFile.class); + Mockito.when(ret.getHash()).thenReturn(hash); + return ret; + }); + } + + @Test + public void addTest() throws IOException { + try { + Files.write(file, fileContent.getBytes()); + + HashedFile hashedFile = new HashedFile(file, curDir); + + IntersectedFolder folder = new IntersectedFolder(mockedStorage, folderList); + + folder.add(hashedFile); + folder.writeList(); + + List expectedList = Collections.singletonList(file + " " + + hashedFile.getHash()); + + Assert.assertEquals(expectedList, Files.readAllLines(folderList)); + } finally { + Files.deleteIfExists(folderList); + Files.deleteIfExists(file); + } + } + + @Test + public void getFileTest() throws IOException { + try { + Files.write(file, fileContent.getBytes()); + String hash = HashedFile.calcFileHash(file.toString()); + + Files.write(folderList, (file.toString() + " " + hash + "\n").getBytes()); + + IntersectedFolder folder = new IntersectedFolder(mockedStorage, folderList); + + Assert.assertEquals(hash, folder.getFile(file).getHash()); + } finally { + Files.deleteIfExists(file); + Files.deleteIfExists(folderList); + } + } + + @Test + public void deleteTest() throws VCS.NoSuchFileException, IOException { + try { + Files.write(file, fileContent.getBytes()); + + Files.write(folderList, (file.toString() + " " + + HashedFile.calcFileHash(file.toString()) + "\n").getBytes()); + + IntersectedFolder folder = new IntersectedFolder(mockedStorage, folderList); + folder.delete(file); + folder.writeList(); + + Assert.assertTrue(Files.readAllLines(folderList).isEmpty()); + Mockito.verify(mockedStorage).delete(Mockito.anyString()); + } finally { + Files.delete(file); + Files.delete(folderList); + } + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/MergerTest.java b/src/test/java/ru/spbau/gusev/vcs/MergerTest.java new file mode 100644 index 0000000..6bed962 --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/MergerTest.java @@ -0,0 +1,157 @@ +package ru.spbau.gusev.vcs; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.*; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class MergerTest { + @Test + public void simpleTest() throws IOException, VCS.RepoAlreadyExistsException, + VCS.BadRepoException, VCS.BadPositionException, VCS.NoSuchFileException, + VCS.NoSuchCommitException, VCS.BranchAlreadyExistsException, + NoSuchAlgorithmException, VCS.NoSuchBranchException { + final Path FILE_TO_KEEP = Paths.get("file_keep"); + final Path FILE_TO_UPDATE = Paths.get("file_upd"); + final Path FILE_TO_CREATE = Paths.get("file_create"); + final String KEPT_CONTENT = "keep"; + final String CREATED_CONTENT = "created"; + final String V1 = "v1"; + final String V2 = "v2"; + + try (RepoDir repo = new RepoDir()) { + Path storage = Paths.get(RepoDir.ROOT, RepoDir.COMMITS_FILES); + + MessageDigest digest = MessageDigest.getInstance("MD5"); + String hashKeep = new BigInteger(digest.digest( + KEPT_CONTENT.getBytes())).toString(); + String hashV1 = new BigInteger(digest.digest(V1.getBytes())) + .toString(); + String hashV2 = new BigInteger(digest.digest(V2.getBytes())) + .toString(); + String hashCreate = new BigInteger(digest.digest + (CREATED_CONTENT.getBytes())).toString(); + + Files.write(storage.resolve(hashCreate), CREATED_CONTENT.getBytes()); + Files.write(storage.resolve(hashKeep), KEPT_CONTENT.getBytes()); + Files.write(storage.resolve(hashV1), V1.getBytes()); + Files.write(storage.resolve(hashV2), V2.getBytes()); + + Files.write(Paths.get(RepoDir.ROOT, RepoDir.COMMITS_FILES_LIST), + (hashCreate + " 1\n" + hashKeep + " 1\n" + hashV1 + " 1\n" + + hashV2 + " 1\n").getBytes()); + + List files1 = Arrays.asList( + FILE_TO_KEEP.toString() + " " + hashKeep, + FILE_TO_UPDATE.toString() + " " + hashV1); + + repo.commit(1, RepoDir.MASTER, files1, 0, "m1", 0); + + Files.write(Paths.get(RepoDir.ROOT, RepoDir.BRANCHES, + "work"), "1\n".getBytes()); + + List files2 = Arrays.asList( + FILE_TO_CREATE.toString() + " " + hashCreate, + FILE_TO_UPDATE.toString() + " " + hashV2); + repo.commit(2, "work", files2, 0, "m2", 0); + + Files.write(Paths.get(RepoDir.ROOT, RepoDir.POSITION), + "master\n1".getBytes()); + Files.write(Paths.get(RepoDir.ROOT, RepoDir.COMMIT), "3".getBytes()); + Merger.merge(Repository.getExisting(), + Branch.getByName("work", Repository.getExisting())); + + Assert.assertEquals(Collections.singletonList(KEPT_CONTENT), + Files.readAllLines(FILE_TO_KEEP)); + Assert.assertTrue(Files.exists(FILE_TO_CREATE)); + Assert.assertEquals(Collections.singletonList(V2), + Files.readAllLines(FILE_TO_UPDATE)); + + Files.delete(Paths.get(Repository.REPO_DIR_NAME, + "commits_files_list")); + Files.createFile(Paths.get(Repository.REPO_DIR_NAME, + "commits_files_list")); + } finally { + Files.deleteIfExists(FILE_TO_KEEP); + Files.deleteIfExists(FILE_TO_UPDATE); + Files.deleteIfExists(FILE_TO_CREATE); + } + } + + @Test + public void conflictsTest() throws IOException, VCS.RepoAlreadyExistsException, + VCS.BadRepoException, VCS.BadPositionException, VCS.NoSuchFileException, + VCS.NoSuchCommitException, VCS.BranchAlreadyExistsException { + final Path FILE_UPDATED_IN_MASTER = Paths.get("file_upd_master"); + final Path FILE_UPDATED_IN_WORK = Paths.get("file_upd_work"); + final Path FILE_UPDATED_IN_BOTH = Paths.get("file_upd_both"); + final Path FILE_CREATED_IN_MASTER = Paths.get("file_cr_master"); + final Path FILE_CREATED_IN_WORK = Paths.get("file_cr_work"); + final Path FILE_CREATED_IN_BOTH = Paths.get("file_cr_both"); + + List V2 = Collections.singletonList("v5"); + List V3 = Collections.singletonList("v6"); + + try { + Repository repo = Repository.create("usr"); + StagingZone stagingZone = repo.getStagingZone(); + Path curDir = Paths.get("."); + + Files.write(FILE_UPDATED_IN_MASTER, "v4".getBytes()); + Files.write(FILE_UPDATED_IN_WORK, "v4".getBytes()); + Files.write(FILE_UPDATED_IN_BOTH, "v4".getBytes()); + stagingZone.add(new HashedFile(FILE_UPDATED_IN_MASTER, curDir)); + stagingZone.add(new HashedFile(FILE_UPDATED_IN_WORK, curDir)); + stagingZone.add(new HashedFile(FILE_UPDATED_IN_BOTH, curDir)); + Commit masterCommit1 = Commit.create("v4" ,repo); + + Files.write(FILE_UPDATED_IN_MASTER, "v5".getBytes()); + Files.write(FILE_UPDATED_IN_BOTH, "v5".getBytes()); + Files.write(FILE_CREATED_IN_MASTER, "v5".getBytes()); + Files.write(FILE_CREATED_IN_BOTH, "v5".getBytes()); + stagingZone.add(new HashedFile(FILE_UPDATED_IN_MASTER, curDir)); + stagingZone.add(new HashedFile(FILE_UPDATED_IN_BOTH, curDir)); + stagingZone.add(new HashedFile(FILE_CREATED_IN_MASTER, curDir)); + stagingZone.add(new HashedFile(FILE_CREATED_IN_BOTH, curDir)); + Commit masterCommit2 = Commit.create("v5" ,repo); + + repo.checkoutCommit(masterCommit1.getNumber()); + Branch workBranch = Branch.create("work", repo); + repo.setCurrentBranch(workBranch); + Files.write(FILE_UPDATED_IN_WORK, "v6".getBytes()); + Files.write(FILE_UPDATED_IN_BOTH, "v6".getBytes()); + Files.write(FILE_CREATED_IN_WORK, "v6".getBytes()); + Files.write(FILE_CREATED_IN_BOTH, "v6".getBytes()); + stagingZone.add(new HashedFile(FILE_UPDATED_IN_WORK, curDir)); + stagingZone.add(new HashedFile(FILE_UPDATED_IN_BOTH, curDir)); + stagingZone.add(new HashedFile(FILE_CREATED_IN_WORK, curDir)); + stagingZone.add(new HashedFile(FILE_CREATED_IN_BOTH, curDir)); + Commit workCommit = Commit.create("v6", repo); + + repo.checkoutCommit(masterCommit2.getNumber()); + Merger.merge(repo, workBranch); + + Assert.assertEquals(V2, Files.readAllLines(FILE_CREATED_IN_MASTER)); + Assert.assertEquals(V2, Files.readAllLines(FILE_UPDATED_IN_MASTER)); + Assert.assertEquals(V3, Files.readAllLines(FILE_UPDATED_IN_WORK)); + Assert.assertEquals(V3, Files.readAllLines(FILE_CREATED_IN_WORK)); + Assert.assertEquals(V3, Files.readAllLines(FILE_UPDATED_IN_BOTH)); + Assert.assertEquals(V3, Files.readAllLines(FILE_CREATED_IN_BOTH)); + } finally { + HashedDirectory.deleteDir(Repository.REPO_DIR_NAME); + Files.deleteIfExists(FILE_CREATED_IN_BOTH); + Files.deleteIfExists(FILE_CREATED_IN_MASTER); + Files.deleteIfExists(FILE_CREATED_IN_WORK); + Files.deleteIfExists(FILE_UPDATED_IN_BOTH); + Files.deleteIfExists(FILE_UPDATED_IN_MASTER); + Files.deleteIfExists(FILE_UPDATED_IN_WORK); + } + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/RepoDir.java b/src/test/java/ru/spbau/gusev/vcs/RepoDir.java new file mode 100644 index 0000000..77b2705 --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/RepoDir.java @@ -0,0 +1,76 @@ +package ru.spbau.gusev.vcs; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RepoDir implements AutoCloseable { + protected static final String ROOT = ".vcs"; + protected static final String BRANCHES = "branches"; + protected static final String MASTER = "master"; + protected static final String COMMITS = "commits"; + protected static final String METADATA = "metadata"; + protected static final String FILES_LIST = "files_list"; + protected static final String COMMITS_FILES = "commits_files"; + protected static final String COMMIT = "commit"; + protected static final String COMMITS_FILES_LIST = "commits_files_list"; + protected static final String POSITION = "position"; + protected static final String STAGE_LIST = "stage_list"; + protected static final String USER = "user"; + protected static final String USERNAME = "usr"; + protected static final String STAGE = "stage"; + + public RepoDir() throws IOException { + Path repoRoot = Paths.get(ROOT); + + Files.createDirectory(repoRoot); + Files.createDirectory(repoRoot.resolve(BRANCHES)); + Files.createDirectory(repoRoot.resolve(COMMITS)); + Files.createDirectory(repoRoot.resolve(COMMITS_FILES)); + Files.createDirectory(repoRoot.resolve(STAGE)); + Files.createFile(repoRoot.resolve(BRANCHES).resolve(MASTER)); + + Files.write(repoRoot.resolve(COMMIT), "1".getBytes()); + Files.createFile(repoRoot.resolve(COMMITS_FILES_LIST)); + Files.write(repoRoot.resolve(POSITION), (MASTER + "\n0").getBytes()); + Files.createFile(repoRoot.resolve(STAGE_LIST)); + Files.write(repoRoot.resolve(USER), USERNAME.getBytes()); + + commit(0, MASTER, new ArrayList<>(), 0, + "Initial commit.", -1); + } + + + @Override + public void close() throws IOException { + HashedDirectory.deleteDir(ROOT); + } + + public void commit(Integer number, String branch, List files, + long time, String message, Integer parent) + throws IOException { + Path commitRoot = Paths.get(ROOT, COMMITS, number.toString()); + Files.createDirectory(commitRoot); + + Files.write(commitRoot.resolve(FILES_LIST), + String.join("\n", files).getBytes()); + + try (BufferedWriter metadataWriter = + Files.newBufferedWriter(commitRoot.resolve(METADATA))) { + metadataWriter.write(String.valueOf(time) + "\n"); + metadataWriter.write(branch + "\n"); + metadataWriter.write(USERNAME + "\n"); + metadataWriter.write(parent.toString() + "\n"); + metadataWriter.write(message); + } + + Files.write(Paths.get(ROOT, BRANCHES, branch), + (number.toString() + "\n").getBytes(), StandardOpenOption.APPEND); + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/RepositoryTest.java b/src/test/java/ru/spbau/gusev/vcs/RepositoryTest.java new file mode 100644 index 0000000..016ad85 --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/RepositoryTest.java @@ -0,0 +1,101 @@ +package ru.spbau.gusev.vcs; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class RepositoryTest { + @Test + public void createTest() throws IOException, VCS.RepoAlreadyExistsException { + final String USERNAME = "user"; + try { + Repository.create(USERNAME); + + Path repoPath = Paths.get(Repository.REPO_DIR_NAME); + Assert.assertTrue(Files.isDirectory(repoPath)); + Assert.assertTrue(Files.isDirectory(repoPath.resolve( + Repository.BRANCHES_DIR_NAME))); + Assert.assertTrue(Files.isDirectory(repoPath.resolve( + Repository.COMMITS_DIR_NAME))); + + Assert.assertEquals(Files.readAllLines(repoPath.resolve(Repository.USERNAME_FILE)), + Collections.singletonList(USERNAME)); + + Assert.assertEquals( + Files.readAllLines(repoPath.resolve(Repository.COMMITS_COUNTER_FILENAME)), + Collections.singletonList("1")); + + Assert.assertEquals(Arrays.asList("master", "0"), + Files.readAllLines(repoPath.resolve(Repository.POSITION_FILENAME))); + } finally { + HashedDirectory.deleteDir(Paths.get(Repository.REPO_DIR_NAME)); + } + } + + @Test + public void setUserTest() throws + IOException, VCS.RepoAlreadyExistsException, VCS.BadRepoException { + try (RepoDir repo = new RepoDir()) { + final String CUSTOM_USERNAME = "user2"; + Repository.getExisting().setUserName(CUSTOM_USERNAME); + Assert.assertEquals(Files.readAllLines( + Paths.get(Repository.REPO_DIR_NAME, Repository.USERNAME_FILE)), + Collections.singletonList(CUSTOM_USERNAME)); + } + } + + @Test + public void checkoutTest() throws VCS.RepoAlreadyExistsException, IOException, + VCS.NoSuchFileException, VCS.BadPositionException, VCS.BadRepoException, + VCS.NoSuchCommitException, NoSuchAlgorithmException { + final String FILE_1 = "file1"; + final String FILE_2 = "file2"; + final String CONTENT_1_1 = "1.1"; + final String CONTENT_1_2 = "1.2"; + final String CONTENT_2_1 = "2.1"; + final Path path1 = Paths.get(FILE_1); + final Path path2 = Paths.get(FILE_2); + final Path storagePath = Paths.get(RepoDir.ROOT, + RepoDir.COMMITS_FILES); + + try (RepoDir repo = new RepoDir()) { + Repository repository = Repository.getExisting(); + MessageDigest digest = MessageDigest.getInstance("MD5"); + String hash_1_1 = new BigInteger( + digest.digest(CONTENT_1_1.getBytes())).toString(); + String hash_1_2 = new BigInteger( + digest.digest(CONTENT_1_2.getBytes())).toString(); + String hash_2_1 = new BigInteger( + digest.digest(CONTENT_2_1.getBytes())).toString(); + + Files.write(storagePath.resolve(hash_1_1), CONTENT_1_1.getBytes()); + Files.write(storagePath.resolve(hash_1_2), CONTENT_1_2.getBytes()); + Files.write(storagePath.resolve(hash_2_1), CONTENT_2_1.getBytes()); + + List files1 = Collections.singletonList(FILE_1 + " " + hash_1_1); + List files2 = Arrays.asList(FILE_1 + " " + hash_1_2, + FILE_2 + " " + hash_2_1); + + repo.commit(1, RepoDir.MASTER, files1, 0, "msg", 0); + repo.commit(2, RepoDir.MASTER, files2, 0, "msg", 2); + + repository.checkoutCommit(1); + Assert.assertTrue(Files.notExists(path2)); + List file1Content = Files.readAllLines(path1); + Assert.assertEquals(Collections.singletonList("1.1"), file1Content); + } finally { + Files.deleteIfExists(path1); + Files.deleteIfExists(path2); + } + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/StagingZoneTest.java b/src/test/java/ru/spbau/gusev/vcs/StagingZoneTest.java new file mode 100644 index 0000000..c7d436c --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/StagingZoneTest.java @@ -0,0 +1,144 @@ +package ru.spbau.gusev.vcs; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; + +public class StagingZoneTest { + @Test + public void addFileTest() throws IOException, VCS.NoSuchFileException, + VCS.RepoAlreadyExistsException { + final String FILE_NAME = "foo"; + final String FILE_CONTENT = "bar"; + final Path stageDir = Paths.get("stage_dir"); + final Path stageList = Paths.get("stage_list"); + final Path filePath = Paths.get(FILE_NAME); + + try { + Files.write(filePath, FILE_CONTENT.getBytes()); + Files.createDirectory(stageDir); + Files.createFile(stageList); + String expectedHash = FILE_NAME + " " + HashedFile.calcFileHash(FILE_NAME); + + StagingZone stagingZone = new StagingZone(stageDir, stageList); + stagingZone.add(new HashedFile(filePath, Paths.get("."))); + Assert.assertTrue(Files.exists(stageDir.resolve(FILE_NAME))); + List stageHashes = Files.readAllLines(stageList); + Assert.assertEquals(Collections.singletonList(expectedHash), stageHashes); + } finally { + Files.delete(stageList); + HashedDirectory.deleteDir(stageDir); + Files.delete(filePath); + } + } + + @Test + public void wipeTest() throws IOException, VCS.NoSuchFileException { + final String FILE_NAME = "foo"; + final String FILE_CONTENT = "bar"; + final Path stageDir = Paths.get("stage_dir"); + final Path stageList = Paths.get("stage_list"); + Path filePath = stageDir.resolve(FILE_NAME); + + try { + Files.createDirectory(stageDir); + Files.write(filePath, FILE_CONTENT.getBytes()); + + String hash = FILE_NAME + " " + HashedFile.calcFileHash(filePath.toString()); + Files.write(stageList, hash.getBytes()); + + StagingZone stagingZone = new StagingZone(stageDir, stageList); + stagingZone.wipe(); + + Assert.assertEquals(Collections.emptyList(), Files.readAllLines(stageList)); + Assert.assertEquals(0, Files.list(stageDir).count()); + } finally { + Files.delete(stageList); + HashedDirectory.deleteDir(stageDir); + } + } + + @Test + public void removeTest() throws IOException, VCS.NoSuchFileException { + final String FILE_NAME = "foo"; + final String FILE_CONTENT = "bar"; + final Path stageDir = Paths.get("stage_dir"); + final Path stageList = Paths.get("stage_list"); + final Path filePath = stageDir.resolve(FILE_NAME); + + try { + Files.createDirectory(stageDir); + Files.write(filePath, FILE_CONTENT.getBytes()); + + String hash = FILE_NAME + " " + HashedFile.calcFileHash(filePath.toString()); + Files.write(stageList, hash.getBytes()); + + StagingZone stagingZone = new StagingZone(stageDir, stageList); + stagingZone.removeFile(Paths.get(FILE_NAME)); + + Assert.assertEquals(Collections.emptyList(), Files.readAllLines(stageList)); + Assert.assertEquals(0, Files.list(stageDir).count()); + } finally { + Files.delete(stageList); + HashedDirectory.deleteDir(stageDir); + } + } + + @Test + public void containsTest() throws IOException, VCS.NoSuchFileException { + final String FILE_NAME = "foo"; + final String FILE_CONTENT = "bar"; + final Path stageDir = Paths.get("stage_dir"); + final Path stageList = Paths.get("stage_list"); + final Path filePath = stageDir.resolve(FILE_NAME); + + try { + Files.createDirectory(stageDir); + Files.write(filePath, FILE_CONTENT.getBytes()); + + String hash = FILE_NAME + " " + HashedFile.calcFileHash(filePath.toString()); + Files.write(stageList, hash.getBytes()); + + StagingZone stagingZone = new StagingZone(stageDir, stageList); + Assert.assertTrue(stagingZone.contains(Paths.get(FILE_NAME))); + } finally { + Files.delete(stageList); + HashedDirectory.deleteDir(stageDir); + } + } + + @Test + public void getHashedFileTest() throws IOException, VCS.NoSuchFileException { + final String FILE_NAME = "foo"; + final String FILE_CONTENT = "bar"; + final Path stageDir = Paths.get("stage_dir"); + final Path stageList = Paths.get("stage_list"); + final Path stageFilePath = stageDir.resolve(FILE_NAME); + final Path filePath = Paths.get(FILE_NAME); + + try { + Files.createDirectory(stageDir); + Files.write(stageFilePath, FILE_CONTENT.getBytes()); + + String hash = FILE_NAME + " " + HashedFile.calcFileHash(stageFilePath.toString()); + Files.write(stageList, hash.getBytes()); + + StagingZone stagingZone = new StagingZone(stageDir, stageList); + HashedFile hashedFile = stagingZone.getHashedFile(filePath); + Assert.assertEquals(filePath, hashedFile.getName()); + Assert.assertEquals(stageFilePath, hashedFile.getLocation()); + Assert.assertEquals(stageDir, hashedFile.getDir()); + Assert.assertEquals(HashedFile.calcFileHash(stageFilePath.toString()), + hashedFile.getHash()); + } finally { + Files.delete(stageList); + HashedDirectory.deleteDir(stageDir); + } + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/VCSTest.java b/src/test/java/ru/spbau/gusev/vcs/VCSTest.java new file mode 100644 index 0000000..7a3c08f --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/VCSTest.java @@ -0,0 +1,123 @@ +package ru.spbau.gusev.vcs; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; + +public class VCSTest { + private final static Path IGNORE_PATH = Paths.get(".ignore"); + private final static String IGNORED_FILES = ".vcs\n" + + ".git\n" + + "src\n" + + "build\n" + + "gradle\n" + + ".gradle\n" + + ".idea\n" + + ".travis.yml\n" + + ".gitignore\n" + + "build.gradle\n" + + "settings.gradle\n" + + "gradlew\n" + + "gradlew.bat\n" + + "readme.md\n"; + + @Before + public void makeIgnore() throws IOException { + Files.write(IGNORE_PATH, IGNORED_FILES.getBytes()); + } + + @After + public void delIgnore() throws IOException { + Files.deleteIfExists(IGNORE_PATH); + } + + @Test + public void getCreatedTest() throws IOException, VCS.RepoAlreadyExistsException, + VCS.BadRepoException { + Path filePath = Paths.get("file"); + try { + Repository.create("usr"); + Files.createFile(filePath); + Assert.assertEquals(Collections.singletonList(filePath.toString()), + VCS.getCreated()); + } finally { + HashedDirectory.deleteDir(Repository.REPO_DIR_NAME); + Files.deleteIfExists(filePath); + } + } + + @Test + public void getStagedTest() throws IOException, VCS.RepoAlreadyExistsException, + VCS.BadRepoException, VCS.NoSuchFileException { + Path filePath = Paths.get("file"); + try { + Repository.create("usr"); + Files.createFile(filePath); + VCS.addFile(filePath.toString()); + Assert.assertEquals(Collections.singletonList(filePath.toString()), + VCS.getStaged()); + } finally { + HashedDirectory.deleteDir(Repository.REPO_DIR_NAME); + Files.deleteIfExists(filePath); + } + } + + @Test + public void getChangedTest() throws IOException, VCS.RepoAlreadyExistsException, + VCS.BadRepoException, VCS.NoSuchFileException { + Path filePath = Paths.get("file"); + try { + Repository.create("usr"); + Files.createFile(filePath); + VCS.addFile(filePath.toString()); + Files.write(filePath, "V1".getBytes()); + Assert.assertEquals(Collections.singletonList(filePath.toString()), + VCS.getChanged()); + } finally { + HashedDirectory.deleteDir(Repository.REPO_DIR_NAME); + Files.deleteIfExists(filePath); + } + } + + @Test + public void getRemovedTest() throws IOException, VCS.RepoAlreadyExistsException, + VCS.BadRepoException, VCS.NoSuchFileException, VCS.NothingToCommitException, VCS.BadPositionException { + Path filePath = Paths.get("file"); + try { + Repository.create("usr"); + Files.createFile(filePath); + VCS.addFile(filePath.toString()); + VCS.commit("ff"); + VCS.remove(filePath.toString()); + + Assert.assertEquals(Collections.singletonList(filePath.toString()), + VCS.getRemoved()); + } finally { + HashedDirectory.deleteDir(Repository.REPO_DIR_NAME); + Files.deleteIfExists(filePath); + } + } + + @Test + public void cleanTest() throws VCS.RepoAlreadyExistsException, IOException, + VCS.BadRepoException { + Path filePath = Paths.get("file"); + + try { + Repository.create("usr"); + Files.createFile(filePath); + VCS.clean(); + Assert.assertTrue(Files.notExists(filePath)); + } finally { + HashedDirectory.deleteDir(Repository.REPO_DIR_NAME); + Files.deleteIfExists(filePath); + } + } +} diff --git a/src/test/java/ru/spbau/gusev/vcs/WorkingDirectoryTest.java b/src/test/java/ru/spbau/gusev/vcs/WorkingDirectoryTest.java new file mode 100644 index 0000000..21f7c9f --- /dev/null +++ b/src/test/java/ru/spbau/gusev/vcs/WorkingDirectoryTest.java @@ -0,0 +1,113 @@ +package ru.spbau.gusev.vcs; + + +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.stream.Stream; + +public class WorkingDirectoryTest { + private static final Path TEST_DIR = Paths.get("test_dir"); + private static final Path CUR_DIR = Paths.get("."); + + @Test + public void addTest() throws IOException { + Path filePath = Paths.get("foo"); + String fileContent = "bar"; + + try { + Files.createDirectory(TEST_DIR); + WorkingDirectory workingDirectory = new WorkingDirectory(TEST_DIR); + Files.write(filePath, fileContent.getBytes()); + workingDirectory.add(new HashedFile(filePath, CUR_DIR)); + + Assert.assertTrue(Files.isRegularFile(TEST_DIR.resolve(filePath))); + Assert.assertEquals(Collections.singletonList(fileContent), + Files.readAllLines(TEST_DIR.resolve(filePath))); + } finally { + HashedDirectory.deleteDir(TEST_DIR); + Files.delete(filePath); + } + } + + @Test + public void deleteTest() throws IOException { + Path filePath = Paths.get("foo"); + String fileContent = "bar"; + + try { + Files.createDirectory(TEST_DIR); + WorkingDirectory workingDirectory = new WorkingDirectory(TEST_DIR); + Files.write(TEST_DIR.resolve(filePath), fileContent.getBytes()); + workingDirectory.delete(filePath); + + Assert.assertTrue(Files.notExists(TEST_DIR.resolve(filePath))); + } finally { + HashedDirectory.deleteDir(TEST_DIR); + } + } + + @Test + public void deleteIfTest() throws IOException { + Path filePath1 = Paths.get("foo"); + Path filePath2 = Paths.get("bar"); + + try { + Files.createDirectory(TEST_DIR); + WorkingDirectory workingDirectory = new WorkingDirectory(TEST_DIR); + Files.createFile(TEST_DIR.resolve(filePath1)); + Files.createFile(TEST_DIR.resolve(filePath2)); + workingDirectory.deleteIf(filePath1::equals); + + Assert.assertTrue(Files.notExists(TEST_DIR.resolve(filePath1))); + Assert.assertTrue(Files.exists(TEST_DIR.resolve(filePath2))); + } finally { + HashedDirectory.deleteDir(TEST_DIR); + } + } + + @Test + public void getHashedFileTest() throws IOException, VCS.NoSuchFileException { + Path filePath = Paths.get("foo"); + String fileContent = "bar"; + + try { + Files.createDirectory(TEST_DIR); + WorkingDirectory workingDirectory = new WorkingDirectory(TEST_DIR); + Files.write(TEST_DIR.resolve(filePath), fileContent.getBytes()); + + HashedFile hashedFile = workingDirectory.getHashedFile(filePath.toString()); + Assert.assertEquals(TEST_DIR, hashedFile.getDir()); + Assert.assertEquals(filePath, hashedFile.getName()); + Assert.assertEquals(HashedFile.calcFileHash + (TEST_DIR.resolve(filePath).toString()), hashedFile.getHash()); + } finally { + HashedDirectory.deleteDir(TEST_DIR); + } + } + + @Test + public void getFilesTest() throws IOException { + Path filePath1 = Paths.get("foo"); + Path filePath2 = Paths.get("bar"); + + try { + Files.createDirectory(TEST_DIR); + Files.createFile(TEST_DIR.resolve(filePath1)); + Files.createFile(TEST_DIR.resolve(filePath2)); + Files.write(TEST_DIR.resolve(".ignore"), filePath2.toString().getBytes()); + WorkingDirectory workingDirectory = new WorkingDirectory(TEST_DIR); + + Stream filesInDir = workingDirectory.getFiles(); + Assert.assertTrue(filesInDir.map(HashedFile::getName) + .allMatch(filePath1::equals)); + } finally { + HashedDirectory.deleteDir(TEST_DIR); + } + } +}