diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..da721f5 Binary files /dev/null and b/.coverage differ diff --git a/.gitignore b/.gitignore index b6e4761..40d6e05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,7 @@ # Byte-compiled / optimized / DLL files __pycache__/ -*.py[cod] -*$py.class - # C extensions *.so - # Distribution / packaging .Python build/ @@ -26,81 +22,52 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST - # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec - # Installer logs pip-log.txt pip-delete-this-directory.txt - # Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - # Translations *.mo *.pot - # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal - # Flask stuff: instance/ .webassets-cache - # Scrapy stuff: .scrapy - # Sphinx documentation docs/_build/ - # PyBuilder target/ - # Jupyter Notebook .ipynb_checkpoints - # IPython profile_default/ ipython_config.py - # pyenv .python-version - # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock - # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ - # Celery stuff celerybeat-schedule celerybeat.pid - # SageMath parsed files *.sage.py - # Environments .env .venv @@ -109,21 +76,21 @@ venv/ ENV/ env.bak/ venv.bak/ - # Spyder project settings .spyderproject .spyproject - # Rope project settings .ropeproject - # mkdocs documentation /site - # mypy .mypy_cache/ .dmypy.json dmypy.json - +json # Pyre type checker .pyre/ +.wit +/.idea/ +wit_exercises +\__pycache__ \ No newline at end of file diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..3a2ebe1 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,3 @@ +[settings] +profile=black +known_first_party=add,branch,checkout,commit,errors,graph,init,merge,status,utils,wit diff --git a/README.md b/README.md new file mode 100644 index 0000000..27f599d --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Wit + +Wit is a version control system that simulates Git's behavior, which includes commands such as add, branch, commit, status, and checkout. I also implemented a basic merge and graph, which is a kind of graphical log. + +In order to run Wit, follow the next steps: +1. git clone the repo +2. create a vitrual environnment +3. install the requirements fron requirenents.txt file +4. install the packeges of the project (using "pip install -e .") +5. go to the folder you want to beckup and set as a Wit repository +6. to start a repository, run "python \wit\project\wit.py init" +7. you can always run "python \wit\project\wit.py -- help" to see all possible commands diff --git a/project/__init__.py b/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/add.py b/project/add.py new file mode 100644 index 0000000..ab988af --- /dev/null +++ b/project/add.py @@ -0,0 +1,43 @@ +import shutil +from pathlib import Path +from typing import Union + +from project.errors import WitError +from project.utils import get_repository_path, get_staging_area_path + + +def add_function(path_to_add: Union[str, Path]) -> None: + path = Path(path_to_add).resolve() + repository = get_repository_path(path) + if not repository: + raise WitError("<.wit> file not found") + copy_to_staging_area(path, repository) + + +def copy_to_staging_area(path: Path, repository: Path) -> None: + staging_area_path = get_staging_area_path(repository) + relative_path = path.relative_to(repository) + if path.is_dir(): + copy_dir_to_staging_area(staging_area_path, path, relative_path) + else: + copy_file_to_staging_area(staging_area_path, path, relative_path) + + +def copy_file_to_staging_area( + staging_area_path: Path, path: Path, relative_path: Path +) -> None: + parents_path = relative_path.parents[0] + destination = staging_area_path + if parents_path.name: + destination = staging_area_path / parents_path + destination.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + + +def copy_dir_to_staging_area( + staging_area_path: Path, path: Path, relative_path: Path +) -> None: + destination = staging_area_path / relative_path + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(path, destination) diff --git a/project/branch.py b/project/branch.py new file mode 100644 index 0000000..9c385c1 --- /dev/null +++ b/project/branch.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from project.errors import BranchExistsError, BranchNotCreatedError, WitError +from project.utils import ( + get_commits_by_branches, + get_head_reference, + get_references_path, + get_repository_path, +) + + +def branch_function(name: str) -> None: + repository = get_repository_path(Path.cwd()) + if not repository: + raise WitError("<.wit> file not found") + references_file = get_references_path(repository) + if not references_file.exists(): + raise BranchNotCreatedError( + "No commit was done yet. Can't create a new branch." + ) + existing_branches = get_commits_by_branches(references_file) + if name in existing_branches: + raise BranchExistsError(f"Branch {name} already exists.") + commit_id = get_head_reference(repository) + with references_file.open("a") as ref_file: + ref_file.write(f"\n{name}={commit_id}") diff --git a/project/checkout.py b/project/checkout.py new file mode 100644 index 0000000..6018f1e --- /dev/null +++ b/project/checkout.py @@ -0,0 +1,79 @@ +import shutil +from pathlib import Path +from typing import Union + +from project.errors import BranchDoesntExistError, WitError +from project.utils import ( + get_activated_path, + get_all_files_in_directory_and_subs, + get_commit_id_of_branch, + get_commit_path, + get_references_path, + get_repository_path, + get_staging_area_path, + raise_for_unsaved_work, +) + + +def checkout_function(commit_id_or_branch: str) -> None: + repository = get_repository_path(Path.cwd()) + if not repository: + raise WitError("<.wit> file not found") + raise_for_unsaved_work(repository) + references_file = get_references_path(repository) + commit_id = get_commit_id_of_branch( + repository, commit_id_or_branch, references_file + ) + if not commit_id: + raise BranchDoesntExistError("Branch doesn't exist.") + if commit_id != commit_id_or_branch: + write_activated(commit_id_or_branch, repository) + else: + write_activated("", repository) + commit_path = get_commit_path(repository, commit_id) + update_files_in_main_folder(commit_path, repository) + update_head_in_references_file(commit_id, references_file) + staging_area_path = get_staging_area_path(repository) + update_staging_area_folder(staging_area_path, commit_path) + + +def write_activated(commit_id_or_branch: str, repository: Path) -> None: + activated_path = get_activated_path(repository) + activated_path.write_text(commit_id_or_branch) + + +def update_head_in_references_file(commit_id: str, references_file: Path) -> None: + with references_file.open() as file: + file.readline() + branches_txt = file.read() + references_file.write_text(f"HEAD={commit_id}\n{branches_txt}") + + +def update_files_in_main_folder( + commit_path: Union[str, Path], repository: Path +) -> None: + files_committed = get_all_files_in_directory_and_subs(commit_path) + for committed_file in files_committed: + path_in_commit = commit_path / committed_file + path_in_repository = repository / committed_file + update_file_in_repository(path_in_commit, path_in_repository) + + +def update_file_in_repository(path_in_commit: Path, path_in_repository: Path) -> None: + if path_in_commit.is_file(): + content = path_in_commit.read_text() + path_in_repository.write_text(content) + + +def update_staging_area_folder(staging_area_path: Path, commit_path: Path) -> None: + for file_or_dir in Path(staging_area_path).iterdir(): + if file_or_dir.is_file(): + file_or_dir.unlink() + else: + shutil.rmtree(file_or_dir) + for file in Path(commit_path).iterdir(): + rel_path = Path(file).relative_to(commit_path) + if file.is_file(): + shutil.copy2(file, staging_area_path / rel_path) + else: + shutil.copytree(file, staging_area_path / rel_path) diff --git a/project/commit.py b/project/commit.py new file mode 100644 index 0000000..7a3117b --- /dev/null +++ b/project/commit.py @@ -0,0 +1,111 @@ +import datetime +import os +import random +import re +import shutil +from pathlib import Path +from typing import Optional + +from project.errors import WitError +from project.utils import ( + get_activated_branch, + get_commit_path, + get_head_reference, + get_references_path, + get_repository_path, + get_staging_area_path, +) + +LENGTH = 20 +CHARS = "1234567890abcdef" + + +def commit_function(message: str, second_parent: Optional[str] = None) -> str: + repository = get_repository_path(Path.cwd()) + if not repository: + raise WitError("<.wit> file not found") + new_commit_id = create_commit_id() + create_commit_folder(new_commit_id, repository) + create_commit_txt_file(repository, new_commit_id, message, second_parent) + save_files_in_new_commit(repository, new_commit_id) + write_references(new_commit_id, repository) + return new_commit_id + + +def create_commit_folder(new_commit_id: str, repository: Path) -> None: + path_of_new_folder = get_commit_path(repository, new_commit_id) + os.mkdir(path_of_new_folder) + + +def create_commit_id() -> str: + commit_id = "".join(random.choices(CHARS, k=LENGTH)) + return commit_id + + +def create_commit_txt_file( + repository: Path, + new_commit_id: str, + message: str, + second_parent: Optional[str] = None, +) -> None: + parent_head = get_head_reference(repository) + if second_parent: + parent_head += ", " + second_parent + new_commit_path = get_commit_path(repository, new_commit_id) + txt_file = new_commit_path.with_suffix(".txt") + txt_file.write_text( + f"parent = {parent_head if parent_head else None}\n" + f"date = {datetime.datetime.now().strftime('%c')}\n" + f"message = {message}" + ) + + +def save_files_in_new_commit(repository: Path, new_commit_id: str) -> None: + staging_area_path = get_staging_area_path(repository) + commit_path = get_commit_path(repository, new_commit_id) + for item in staging_area_path.iterdir(): + src = staging_area_path / item.name + dst = commit_path / item.name + if src.is_file(): + shutil.copy2(src, dst) + else: + shutil.copytree(src, dst) + + +def write_references(commit_id: str, repository: Path) -> None: + references_file = get_references_path(repository) + parent_head = get_head_reference(repository) + activated_branch = get_activated_branch(repository) + if references_file.exists(): + change_head_and_branch_id( + activated_branch, commit_id, parent_head, references_file + ) + else: + change_only_head(activated_branch, commit_id, references_file) + + +def change_only_head( + activated_branch: str, commit_id: str, references_file: Path +) -> None: + new_line = "=".join((activated_branch, commit_id)) + references_file.write_text(f"HEAD={commit_id}\n{new_line}") + + +def change_head_and_branch_id( + activated_branch: str, commit_id: str, parent_head: str, references_file: Path +) -> None: + activated_regex = rf"^{activated_branch}={parent_head}$" + head_regex = rf"^HEAD={parent_head}$" + references_data = references_file.read_text() + activated_match = re.findall(activated_regex, references_data, flags=re.MULTILINE) + if activated_match: + references_data = re.sub( + activated_regex, + f"{activated_branch}={commit_id}", + references_data, + flags=re.MULTILINE, + ) + new_references_content = re.sub( + head_regex, f"HEAD={commit_id}", references_data, flags=re.MULTILINE + ) + references_file.write_text(new_references_content) diff --git a/project/errors.py b/project/errors.py new file mode 100644 index 0000000..17e3acf --- /dev/null +++ b/project/errors.py @@ -0,0 +1,26 @@ +class WitError(Exception): + pass + + +class FilesDoNotMatchError(WitError): + pass + + +class BranchDoesntExistError(WitError): + pass + + +class BranchExistsError(WitError): + pass + + +class WitExistsError(WitError): + pass + + +class MergeError(WitError): + pass + + +class BranchNotCreatedError(WitError): + pass diff --git a/project/graph.py b/project/graph.py new file mode 100644 index 0000000..e89b020 --- /dev/null +++ b/project/graph.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Optional + +from graphviz import Digraph + +from project.errors import WitError +from project.utils import ( + PARENT_ID_REGEX, + get_head_reference, + get_images_path, + get_repository_path, + get_wit_path, +) + + +def graph_function() -> Optional[str]: + repository = get_repository_path(Path.cwd()) + if not repository: + raise WitError("<.wit> file not found") + current_commit_id = get_head_reference(repository) + commit_id = current_commit_id + dot = init_graph() + wit_dir = get_wit_path(repository) + images_path = get_images_path(repository) + create_nodes(commit_id, dot, images_path) + dot.render( + filename=f"Graph_{current_commit_id}", + directory=wit_dir, + view=True, + cleanup=True, + ) + return dot.source + + +def create_nodes(commit_id: str, dot: Digraph, images_path: Path) -> None: + while commit_id: + commit_file = images_path / (commit_id + ".txt") + create_node(commit_id, dot) + commit_txt = commit_file.read_text() + parent_id_match = PARENT_ID_REGEX.match(commit_txt) + if parent_id_match: + parent_id_1 = parent_id_match.group("commit_id_1") + parent_id_2 = parent_id_match.group("commit_id_2") + create_node(parent_id_1, dot) + dot.edge(commit_id, parent_id_1) + if parent_id_2: + create_node(parent_id_2, dot) + dot.edge(commit_id, parent_id_2) + create_nodes(parent_id_2, dot, images_path) + commit_id = parent_id_1 if parent_id_match else "" + + +def create_node(commit_id: str, dot: Digraph) -> None: + dot.node(commit_id, commit_id[:6] + "...") + + +def init_graph() -> Digraph: + dot = Digraph( + "graph_function", + comment="Graph", + node_attr={"color": "lightblue2", "style": "filled", "shape": "circle"}, + strict=True, + ) + dot.attr(rankdir="LR", size="8,5") + return dot diff --git a/project/init.py b/project/init.py new file mode 100644 index 0000000..4eb8704 --- /dev/null +++ b/project/init.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from project.errors import WitExistsError +from project.utils import ( + get_activated_path, + get_images_path, + get_staging_area_path, + get_wit_path, +) + + +def init_function() -> None: + cwd = Path.cwd() + wit_path = get_wit_path(cwd) + if wit_path.exists(): + raise WitExistsError("The folder is already a wit directory.") + wit_path.mkdir() + images_path = get_images_path(cwd) + images_path.mkdir() + staging_area_path = get_staging_area_path(cwd) + staging_area_path.mkdir() + activated_path = get_activated_path(cwd) + activated_path.write_text("master") diff --git a/project/merge.py b/project/merge.py new file mode 100644 index 0000000..1cc99df --- /dev/null +++ b/project/merge.py @@ -0,0 +1,179 @@ +from collections import namedtuple +from pathlib import Path +from typing import Iterator + +from project.commit import commit_function +from project.errors import MergeError, WitError +from project.utils import ( + PARENT_ID_REGEX, + get_activated_branch, + get_all_files_in_directory_and_subs, + get_commit_id_of_branch, + get_commit_path, + get_head_reference, + get_images_path, + get_references_path, + get_repository_path, + get_staging_area_path, + raise_for_unsaved_work, +) + + +Paths = namedtuple("Paths", ["branch", "common", "staging_area", "repository"]) + + +def merge_function(branch_name: str) -> None: + repository = get_repository_path(Path.cwd()) + if not repository: + raise WitError("<.wit> file not found") + activated = get_activated_branch(repository) + if branch_name == activated: + raise MergeError("Head is already at the branch you are trying to merge.") + references_file = get_references_path(repository) + head_reference = get_head_reference(repository) + branch_commit_id = get_commit_id_of_branch(repository, branch_name, references_file) + raise_for_unsaved_work(repository) + common_commit_id = get_common_commit_id( + repository, + branch_commit_id, + head_reference, + ) + common_commit_path = get_commit_path(repository, common_commit_id) + branch_path = get_commit_path(repository, branch_commit_id) + staging_area_path = get_staging_area_path(repository) + check_common_commit_and_update_staging_area_and_repository( + branch_path, + common_commit_path, + repository, + staging_area_path, + ) + check_branch_and_update_staging_area_and_repository( + branch_path, + common_commit_path, + repository, + staging_area_path, + ) + commit_merge(branch_commit_id, branch_name, head_reference, repository) + + +def check_branch_and_update_staging_area_and_repository( + branch_path: Path, + common_commit_path: Path, + repository: Path, + staging_area_path: Path, +) -> None: + branch_files = get_all_files_in_directory_and_subs(branch_path) + for file_path in branch_files: + paths = Paths( + branch_path / file_path, + common_commit_path / file_path, + staging_area_path / file_path, + repository / file_path, + ) + files_in_common_that_match_file = common_commit_path.glob(str(file_path)) + if not set(files_in_common_that_match_file): + create_new_file_in_staging_area_and_repository(file_path, paths) + + +def create_new_file_in_staging_area_and_repository( + file_path: Path, paths: Paths +) -> None: + if file_path.is_file(): + content_in_branch = paths.branch.read_text() + file_parent = paths.staging_area.parent + if not file_parent.exists(): + file_parent.mkdir(parents=True) + paths.staging_area.write_text(content_in_branch) + paths.repository.write_text(content_in_branch) + else: + paths.staging_area.mkdir(parents=True, exist_ok=True) + paths.repository.mkdir(parents=True, exist_ok=True) + + +def check_common_commit_and_update_staging_area_and_repository( + branch_path: Path, + common_commit_path: Path, + repository: Path, + staging_area_path: Path, +) -> None: + common_files = get_all_files_in_directory_and_subs(common_commit_path) + for file_path in common_files: + if check_if_file_was_deleted(file_path, branch_path, staging_area_path): + raise NotImplementedError( + f"The file {file_path} was deleted. Deleting files not implemented yet." + ) + if file_path.is_file(): + paths = Paths( + branch_path / file_path, + common_commit_path / file_path, + staging_area_path / file_path, + repository / file_path, + ) + update_file_in_staging_area_and_repository(file_path, paths) + + +def check_if_file_was_deleted( + file_path: Path, branch_path: Path, staging_area_path: Path +) -> bool: + files_in_branch_that_match_file = branch_path.glob(str(file_path)) + deleted_in_branch = not set(files_in_branch_that_match_file) + files_in_staging_area_that_match_file = staging_area_path.glob(str(file_path)) + deleted_in_current = not set(files_in_staging_area_that_match_file) + return deleted_in_branch or deleted_in_current + + +def update_file_in_staging_area_and_repository(file_path: Path, paths: Paths) -> None: + content_in_common = paths.common.read_text() + content_in_branch = paths.branch.read_text() + content_in_staging_area = paths.staging_area.read_text() + if content_in_common != content_in_branch: + if content_in_common != content_in_staging_area: + raise NotImplementedError( + f"{file_path} was changed in both branches. Not implemented yet." + ) + paths.staging_area.write_text(content_in_branch) + paths.repository.write_text(content_in_branch) + + +def commit_merge( + branch_commit_id: str, branch_name: str, head_reference: str, repository: Path +) -> None: + activated = get_activated_branch(repository) + current_branch = activated if activated else head_reference + message = f"Commit after merge of {current_branch} and {branch_name}." + commit_function(message, branch_commit_id) + + +def get_common_commit_id( + repository: Path, branch_commit_id: str, head_reference: str +) -> str: + branch_parents = set(get_parents_commits(repository, branch_commit_id)) + head_parents = set(get_parents_commits(repository, head_reference)) + if branch_commit_id in head_parents: + return branch_commit_id + if head_reference in branch_parents: + return head_reference + common = branch_parents.intersection(head_parents) + if common: + return common.pop() + return "" + + +def get_parents_commits(repository: Path, commit_id: str) -> Iterator[str]: + images_path = get_images_path(repository) + yield from get_parents_of_commit_id(commit_id, images_path) + + +def get_parents_of_commit_id(commit_id: str, images_path: Path) -> Iterator[str]: + while commit_id: + commit_file = images_path / (commit_id + ".txt") + commit_txt = commit_file.read_text() + parent_id_match = PARENT_ID_REGEX.match(commit_txt) + if parent_id_match: + parent_id_1 = parent_id_match.group("commit_id_1") + parent_id_2 = parent_id_match.group("commit_id_2") + yield parent_id_1 + if parent_id_2: + yield parent_id_2 + yield from get_parents_of_commit_id(parent_id_2, images_path) + commit_id = parent_id_1 if parent_id_match else "" diff --git a/project/status.py b/project/status.py new file mode 100644 index 0000000..02e8f41 --- /dev/null +++ b/project/status.py @@ -0,0 +1,52 @@ +from pathlib import Path +from typing import Iterator + +from project.errors import WitError +from project.utils import ( + get_all_files_in_repository_and_subs, + get_changes_not_staged_for_commit, + get_changes_to_be_committed, + get_head_reference, + get_repository_path, + get_staging_area_path, +) + + +def status_function() -> tuple[Iterator[Path], Iterator[Path], Iterator[Path]]: + repository = get_repository_path(Path.cwd()) + if not repository: + raise WitError("<.wit> file not found") + last_commit_id = get_head_reference(repository) + message_if_no_commit = "No commit was done yet." + changes_to_be_committed = stringify_files(get_changes_to_be_committed(repository)) + changes_not_staged_for_commit = stringify_files( + get_changes_not_staged_for_commit(repository) + ) + untracked_files = stringify_files(get_untracked_files(repository)) + output = ( + f"###Commit id:###\n{last_commit_id if last_commit_id else message_if_no_commit}\n\n" + f"###Changes to be committed:###\n{changes_to_be_committed}\n\n" + f"###Changes not staged for commit:###\n{changes_not_staged_for_commit}\n\n" + f"###Untracked files:###\n{untracked_files}\n" + ) + print(output) + return ( + get_changes_to_be_committed(repository), + get_changes_not_staged_for_commit(repository), + get_untracked_files(repository), + ) + + +def stringify_files(files: Iterator[Path]) -> str: + return "\n".join([str(file_name) for file_name in files]) + + +def get_untracked_files(repository: Path) -> Iterator[Path]: + staging_area_path = get_staging_area_path(repository) + files_in_repository = get_all_files_in_repository_and_subs(repository) + for file_path in files_in_repository: + files_in_staging_area_that_match_file_path = staging_area_path.glob( + str(file_path) + ) + if not set(files_in_staging_area_that_match_file_path): + yield file_path diff --git a/project/utils.py b/project/utils.py new file mode 100644 index 0000000..374b2fd --- /dev/null +++ b/project/utils.py @@ -0,0 +1,157 @@ +import os +import re +from pathlib import Path +from typing import Iterator, Optional + +from project.errors import FilesDoNotMatchError + + +BRANCH_REGEX = re.compile( + r"^" + r"(?P\w+)" + r"=" + r"(?P\w{20})" + r"$", + flags=re.MULTILINE, +) + + +PARENT_ID_REGEX = re.compile( + r"^parent = " + r"(?P\w{20})" + r"(, (?P\w{20}))" + r"?$", + flags=re.MULTILINE, +) + + +def get_repository_path(path: Path) -> Optional[Path]: + if path.is_dir(): + if set(path.glob(".wit")): + return path + for directory in path.parents: + if set(directory.glob(".wit")): + return directory + return None + + +def get_commit_id_of_branch( + repository: Path, + branch: str, + references_file: Path, +) -> str: + branches_commits_dict = get_commits_by_branches(references_file) + if branch in branches_commits_dict: + return branches_commits_dict[branch] + if branch in list(get_all_commits(repository)): + return branch + return "" + + +def get_all_commits(repository: Path) -> Iterator[str]: + images_path = get_images_path(repository) + for file_name in images_path.iterdir(): + if file_name.is_dir(): + yield file_name.name + + +def get_commits_by_branches(references_file: Path) -> dict[str, str]: + with references_file.open() as file: + branches_data = file.read() + branch_matches = BRANCH_REGEX.findall(branches_data) + return dict(branch_matches) + + +def get_head_reference(repository: Path) -> str: + references_file = get_references_path(repository) + if references_file.exists(): + return get_commit_id_of_branch(repository, "HEAD", references_file) + return "" + + +def get_activated_branch(repository: Path) -> str: + activated_path = get_activated_path(repository) + return activated_path.read_text() + + +def get_all_files_in_directory_and_subs(directory: Path) -> Iterator[Path]: + for root, dirs, files in os.walk(directory, topdown=True): + if files: + for file in files: + yield (Path(root) / file).relative_to(directory) + else: + if not dirs: + yield Path(root).relative_to(directory) + + +def get_all_files_in_repository_and_subs(repository: Path) -> Iterator[Path]: + for root, dirs, files in os.walk(repository, topdown=True): + if files: + for file in files: + if ".wit" not in root: + yield (Path(root) / file).relative_to(repository) + else: + if ".wit" not in root and not dirs: + yield Path(root).relative_to(repository) + + +def get_wit_path(repository: Path) -> Path: + return repository / ".wit" + + +def get_staging_area_path(repository: Path) -> Path: + return get_wit_path(repository) / "staging_area" + + +def get_references_path(repository: Path) -> Path: + return get_wit_path(repository) / "references.txt" + + +def get_images_path(repository: Path) -> Path: + return get_wit_path(repository) / "images" + + +def get_commit_path(repository: Path, commit_id: str) -> Path: + return get_images_path(repository) / commit_id + + +def get_activated_path(repository: Path) -> Path: + return get_wit_path(repository) / "activated.txt" + + +def check_if_file_changed(file_path: Path, dir_1: Path, dir_2: Path) -> bool: + content_in_dir_1 = (dir_1 / file_path).read_text() + content_in_dir_2 = (dir_2 / file_path).read_text() + return content_in_dir_1 != content_in_dir_2 + + +def get_changes_to_be_committed(repository: Path) -> Optional[Iterator[Path]]: + staging_area_path = get_staging_area_path(repository) + last_commit_id = get_head_reference(repository) + files_in_staging_area = get_all_files_in_directory_and_subs(staging_area_path) + if not last_commit_id: + return None + commit_path = get_commit_path(repository, last_commit_id) + for file_path in files_in_staging_area: + files_in_last_commit_that_match_file_path = commit_path.glob(str(file_path)) + if not set(files_in_last_commit_that_match_file_path): + yield file_path + elif file_path.is_file(): + if check_if_file_changed(file_path, staging_area_path, commit_path): + yield file_path + + +def get_changes_not_staged_for_commit(repository: Path) -> Optional[Iterator[Path]]: + staging_area_path = get_staging_area_path(repository) + files_in_staging_area = get_all_files_in_directory_and_subs(staging_area_path) + for file_path in files_in_staging_area: + if file_path.is_file(): + if check_if_file_changed(file_path, staging_area_path, repository): + yield file_path + + +def raise_for_unsaved_work(repository: Path) -> None: + files_added_since_last_commit = set(get_changes_to_be_committed(repository)) + changed_files_since_last_commit = set(get_changes_not_staged_for_commit(repository)) + if files_added_since_last_commit or changed_files_since_last_commit: + raise FilesDoNotMatchError("There are files added or changed since last commit") diff --git a/project/wit.py b/project/wit.py new file mode 100644 index 0000000..91bd2f6 --- /dev/null +++ b/project/wit.py @@ -0,0 +1,74 @@ +import click + +from project.add import add_function +from project.branch import branch_function +from project.checkout import checkout_function +from project.commit import commit_function +from project.graph import graph_function +from project.init import init_function +from project.merge import merge_function +from project.status import status_function + + +@click.group() +def cli(): + pass + + +@cli.command() +def init(): + """Create a wit folder in the directory.""" + init_function() + + +@cli.command() +@click.argument("path", type=click.Path(exists=True)) +def add(path): + """Add file or folder to staging area.""" + add_function(path) + + +@cli.command() +@click.argument("message") +def commit(message): + """Save image of current files in staging area. + Save a message. + """ + commit_function(message) + + +@cli.command() +def status(): + """Show the status of files in directory.""" + status_function() + + +@cli.command() +@click.argument("commit_id_or_branch") +def checkout(commit_id_or_branch): + """Switch to another commit or branch, which will be activated.""" + checkout_function(commit_id_or_branch) + + +@cli.command() +@click.argument("branch_name") +def branch(branch_name): + """Create a new branch.""" + branch_function(branch_name) + + +@cli.command() +def graph(): + """Show graph of commits from current commit to the first.""" + graph_function() + + +@cli.command() +@click.argument("branch_name") +def merge(branch_name): + """Merge the files in the current commit with the branch.""" + merge_function(branch_name) + + +if __name__ == "__main__": + cli() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2e4865b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pytest~=7.0.1 +click~=8.0.4 +graphviz~=0.19.1 +setuptools~=57.4.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..dadd890 --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup + +setup( + name="project", + package_dir={"project": "project"}, + include_package_data=True, + packages=["project"], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..31af5b6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,72 @@ +import os + +import pytest + +from project.add import add_function +from project.commit import commit_function +from project.init import init_function +from project.utils import get_staging_area_path + + +@pytest.fixture() +def test_folder(tmp_path): + test_wit = tmp_path / "test_wit" + last_folder = test_wit / "folder1" / "folder2" + last_folder.mkdir(parents=True, exist_ok=True) + (test_wit / "file1.txt").write_text("") + (test_wit / "folder1" / "file2.txt").write_text("") + (last_folder / "file3.txt").write_text("") + empty_folder = test_wit / "empty" + empty_folder.mkdir() + os.chdir(test_wit) + init_function() + return test_wit + + +@pytest.fixture() +def file1(test_folder): + return test_folder / "file1.txt" + + +@pytest.fixture() +def folder1(test_folder): + return test_folder / "folder1" + + +@pytest.fixture() +def file2(folder1): + return folder1 / "file2.txt" + + +@pytest.fixture() +def folder2(folder1): + return folder1 / "folder2" + + +@pytest.fixture() +def file3(folder2): + return folder2 / "file3.txt" + + +@pytest.fixture() +def empty_folder(test_folder): + return test_folder / "empty" + + +def change_add_and_commit_file(file_path, txt): + file_path.write_text(txt) + add_function(file_path) + commit_id = commit_function("") + return commit_id + + +def add_new_file_and_commit(folder): + new = folder / "file_path.txt" + change_add_and_commit_file(new, "file_path") + return new + + +def get_file_path_in_staging_area(file_path, folder): + staging_area = get_staging_area_path(folder) + path_in_staging_area = staging_area / file_path.relative_to(folder) + return path_in_staging_area diff --git a/tests/test_add.py b/tests/test_add.py new file mode 100644 index 0000000..5e3a5e0 --- /dev/null +++ b/tests/test_add.py @@ -0,0 +1,34 @@ +import os + +import pytest + +from project.add import add_function +from project.errors import WitError +from tests.conftest import get_file_path_in_staging_area + + +def test_raise_wit_error(tmp_path): + os.chdir(tmp_path) + test_file = tmp_path / "test.txt" + test_file.write_text("") + with pytest.raises(WitError): + add_function(test_file.name) + + +@pytest.mark.parametrize( + "file_to_add", ["file1.txt", r"folder1\folder2\file3.txt", r"folder1\folder2"] +) +def test_add_function(test_folder, file_to_add): + os.chdir(test_folder) + file_path = test_folder / file_to_add + add_function(file_path) + path_in_staging_area = get_file_path_in_staging_area(file_path, test_folder) + assert path_in_staging_area.exists() + + +def test_add_dir_already_added(test_folder, file3, folder2): + add_function(folder2) + file3.write_text("1") + add_function(folder2) + file3_in_staging_area = get_file_path_in_staging_area(file3, test_folder) + assert file3_in_staging_area.read_text() == "1" diff --git a/tests/test_branch.py b/tests/test_branch.py new file mode 100644 index 0000000..7b44edf --- /dev/null +++ b/tests/test_branch.py @@ -0,0 +1,35 @@ +import os + +import pytest + +from project.branch import branch_function +from project.errors import BranchExistsError, BranchNotCreatedError, WitError +from project.utils import get_head_reference, get_references_path +from tests.conftest import change_add_and_commit_file + + +def test_raise_wit_error(tmp_path): + os.chdir(tmp_path) + with pytest.raises(WitError): + branch_function("") + + +def test_branch_function(test_folder, file2): + os.chdir(test_folder) + change_add_and_commit_file(file2, "test branch") + branch_function("TestBranch") + commit_id = get_head_reference(test_folder) + references_file = get_references_path(test_folder) + assert f"TestBranch={commit_id}" in references_file.read_text() + + +def test_branch_exists_error(test_folder, file2): + change_add_and_commit_file(file2, "test branch") + branch_function("TestBranch") + with pytest.raises(BranchExistsError): + branch_function("TestBranch") + + +def test_branch_not_created_error(test_folder): + with pytest.raises(BranchNotCreatedError): + branch_function("branch") diff --git a/tests/test_checkout.py b/tests/test_checkout.py new file mode 100644 index 0000000..80e6f25 --- /dev/null +++ b/tests/test_checkout.py @@ -0,0 +1,72 @@ +import os + +import pytest + +from project.add import add_function +from project.branch import branch_function +from project.checkout import checkout_function +from project.errors import BranchDoesntExistError, FilesDoNotMatchError, WitError +from project.utils import get_activated_branch, get_head_reference +from tests.conftest import ( + add_new_file_and_commit, + change_add_and_commit_file, + get_file_path_in_staging_area, +) + + +def test_raise_wit_error(tmp_path): + os.chdir(tmp_path) + with pytest.raises(WitError): + checkout_function("branch") + + +def test_raise_branch_doesnt_exist_error(test_folder, file1): + os.chdir(test_folder) + change_add_and_commit_file(file1, "") + with pytest.raises(BranchDoesntExistError): + checkout_function("branch") + + +def test_raise_files_do_not_match_error_changed(test_folder, file1): + change_add_and_commit_file(file1, "") + branch_function("branch") + checkout_function("branch") + file1.write_text("FilesDoNotMatchError1") + with pytest.raises(FilesDoNotMatchError): + checkout_function("master") + + +def test_raise_files_do_not_match_error_added(test_folder, file1, file2): + change_add_and_commit_file(file1, "") + add_function(file2) + with pytest.raises(FilesDoNotMatchError): + checkout_function("master") + + +def test_checkout_function(test_folder, file1): + change_add_and_commit_file(file1, "") + branch_function("branch") + checkout_function("branch") + change_add_and_commit_file(file1, "changed") + checkout_function("master") + assert get_activated_branch(test_folder) == "master" + assert file1.read_text() == "" + + +def test_checkout_id(test_folder, file1, file2): + change_add_and_commit_file(file1, "") + test_id = get_head_reference(test_folder) + change_add_and_commit_file(file2, "") + checkout_function(test_id) + activated_branch = get_activated_branch(test_folder) + assert not activated_branch + + +def test_add_new_file_and_checkout(test_folder, file1, file2): + change_add_and_commit_file(file1, "") + branch_function("branch") + checkout_function("branch") + new = add_new_file_and_commit(test_folder) + checkout_function("master") + new_in_staging_area = get_file_path_in_staging_area(new, test_folder) + assert not new_in_staging_area.exists() diff --git a/tests/test_commit.py b/tests/test_commit.py new file mode 100644 index 0000000..b5af480 --- /dev/null +++ b/tests/test_commit.py @@ -0,0 +1,31 @@ +import os + +import pytest + +from project.add import add_function +from project.commit import commit_function +from project.errors import WitError +from project.utils import get_commit_path, get_head_reference +from tests.conftest import change_add_and_commit_file + + +def test_raise_wit_error(tmp_path): + os.chdir(tmp_path) + with pytest.raises(WitError): + commit_function("") + + +def test_commit(test_folder, file1): + os.chdir(test_folder) + add_function(file1) + commit_function("test commit") + commit_id = get_head_reference(test_folder) + commit_txt_file = get_commit_path(test_folder, commit_id).with_suffix(".txt") + assert commit_id + assert "test commit" in commit_txt_file.read_text() + + +def test_second_commit(test_folder, file1): + change_add_and_commit_file(file1, "") + new_commit_id = change_add_and_commit_file(file1, "1") + assert get_head_reference(test_folder) == new_commit_id diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..5c3e735 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,50 @@ +import os + +import pytest + +from project.branch import branch_function +from project.checkout import checkout_function +from project.errors import WitError +from project.graph import graph_function +from project.merge import get_parents_commits, merge_function +from project.utils import ( + get_commit_id_of_branch, + get_head_reference, + get_references_path, + get_wit_path, +) +from tests.conftest import change_add_and_commit_file + + +def test_raise_wit_error(tmp_path, test_folder): + os.chdir(tmp_path) + with pytest.raises(WitError): + graph_function() + + +def test_graph_function(test_folder, file1, file3): + os.chdir(test_folder) + change_add_and_commit_file(file1, "") + branch_function("branch1") + checkout_function("branch1") + change_add_and_commit_file(file3, "1") + checkout_function("master") + merge_function("branch1") + references_file = get_references_path(test_folder) + branch_commit_id = get_commit_id_of_branch(test_folder, "branch1", references_file) + dot_source = graph_function() + current_commit_id = get_head_reference(test_folder) + wit_dir = get_wit_path(test_folder) + commits_in_graph = get_commits_in_graph( + branch_commit_id, current_commit_id, test_folder + ) + assert wit_dir / f"Graph_{current_commit_id}.pdf" in set(wit_dir.iterdir()) + for commit in commits_in_graph: + assert commit in dot_source + + +def get_commits_in_graph(branch_commit_id, current_commit_id, test_folder): + parents_commits_of_branch = set(get_parents_commits(test_folder, branch_commit_id)) + parents_commits_of_master = set(get_parents_commits(test_folder, current_commit_id)) + commits_in_graph = parents_commits_of_branch.union(parents_commits_of_master) + return commits_in_graph diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..e217d6d --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,26 @@ +import os + +import pytest + +from project.errors import WitExistsError +from project.init import init_function +from project.utils import ( + get_activated_path, + get_images_path, + get_staging_area_path, + get_wit_path, +) + + +def test_wit_dir(test_folder): + wit_path = get_wit_path(test_folder) + assert wit_path.is_dir() + assert get_images_path(test_folder).is_dir() + assert get_staging_area_path(test_folder).is_dir() + assert get_activated_path(test_folder).read_text() == "master" + + +def test_wit_exists(test_folder): + os.chdir(test_folder) + with pytest.raises(WitExistsError): + init_function() diff --git a/tests/test_merge.py b/tests/test_merge.py new file mode 100644 index 0000000..5c44824 --- /dev/null +++ b/tests/test_merge.py @@ -0,0 +1,114 @@ +import os + +import pytest + +from project.add import add_function +from project.branch import branch_function +from project.checkout import checkout_function +from project.commit import commit_function +from project.errors import MergeError, WitError +from project.merge import merge_function +from tests.conftest import ( + add_new_file_and_commit, + change_add_and_commit_file, + get_file_path_in_staging_area, +) + + +def test_raise_wit_error(tmp_path): + os.chdir(tmp_path) + with pytest.raises(WitError): + merge_function("") + + +def test_raise_merge_error(test_folder): + os.chdir(test_folder) + with pytest.raises(MergeError): + merge_function("master") + + +def test_raise_changed_file_error(test_folder, file1): + change_add_and_commit_file(file1, "") + branch_function("TestChanged") + checkout_function("TestChanged") + change_add_and_commit_file(file1, "changed") + checkout_function("master") + change_add_and_commit_file(file1, "changed again") + with pytest.raises(NotImplementedError): + merge_function("TestChanged") + + +def test_merge_function(test_folder, file1, folder1): + change_add_and_commit_file(file1, "") + branch_function("TestMerge") + checkout_function("TestMerge") + new = add_new_file_and_commit(folder1) + checkout_function("master") + change_add_and_commit_file(file1, "merge") + checkout_function("TestMerge") + merge_function("master") + file1_in_staging_area = get_file_path_in_staging_area(file1, test_folder) + new_in_staging_area = get_file_path_in_staging_area(new, test_folder) + assert file1.read_text() == "merge" + assert file1_in_staging_area.read_text() == "merge" + assert new.read_text() == "file_path" + assert new_in_staging_area.read_text() == "file_path" + + +def test_merge_function2(test_folder, file1, file3): + change_add_and_commit_file(file1, "") + branch_function("branch1") + checkout_function("branch1") + change_add_and_commit_file(file3, "1") + checkout_function("master") + merge_function("branch1") + file1_in_staging_area = get_file_path_in_staging_area(file1, test_folder) + file3_in_staging_area = get_file_path_in_staging_area(file3, test_folder) + assert file1.read_text() == "" + assert file1_in_staging_area.read_text() == "" + assert file3.read_text() == "1" + assert file3_in_staging_area.read_text() == "1" + + +def test_raise_deleted_file_error(test_folder, file1, file2): + change_add_and_commit_file(file1, "") + branch_function("TestMerge") + checkout_function("TestMerge") + file2.unlink() + checkout_function("master") + with pytest.raises(NotImplementedError): + merge_function("TestMerge") + + +def test_create_file_and_merge(test_folder, file1): + change_add_and_commit_file(file1, "") + branch_function("TestMerge") + checkout_function("TestMerge") + new = add_new_file_and_commit(test_folder) + checkout_function("master") + merge_function("TestMerge") + new_in_staging_area = get_file_path_in_staging_area(new, test_folder) + assert new_in_staging_area.read_text() == "file_path" + + +def test_merge_master(test_folder, file1, file2): + change_add_and_commit_file(file1, "") + branch_function("TestMerge") + checkout_function("TestMerge") + change_add_and_commit_file(file2, "1") + merge_function("master") + + +def test_create_folder_and_merge(test_folder, file1): + change_add_and_commit_file(file1, "") + branch_function("TestMerge") + checkout_function("TestMerge") + new = test_folder / "file_path" + new.mkdir() + add_function(new) + commit_function("") + checkout_function("master") + merge_function("TestMerge") + new_in_staging_area = get_file_path_in_staging_area(new, test_folder) + assert new_in_staging_area.exists() + assert new.exists() diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..14396b7 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,63 @@ +import os + +import pytest + +from project.add import add_function +from project.errors import WitError +from project.status import status_function +from project.utils import ( + get_all_files_in_directory_and_subs, + get_all_files_in_repository_and_subs, + get_staging_area_path, +) +from tests.conftest import change_add_and_commit_file + + +def test_raise_wit_error(tmp_path): + os.chdir(tmp_path) + with pytest.raises(WitError): + status_function() + + +def test_status(test_folder, file1, file3): + os.chdir(test_folder) + change_add_and_commit_file(file1, "") + add_function(file3) + file1.write_text("1") + ( + changes_to_be_committed, + changes_not_staged_for_commit, + untracked_files, + ) = status_function() + assert set(changes_to_be_committed) == {file3.relative_to(test_folder)} + assert set(changes_not_staged_for_commit) == {file1.relative_to(test_folder)} + staging_area = get_staging_area_path(test_folder) + all_files = get_all_files_in_repository_and_subs(test_folder) + files_in_staging_area = get_all_files_in_directory_and_subs(staging_area) + assert set(untracked_files).union(set(files_in_staging_area)) == set(all_files) + + +def test_status_if_no_commit_was_done(test_folder): + ( + changes_to_be_committed, + changes_not_staged_for_commit, + untracked_files, + ) = status_function() + all_files = get_all_files_in_repository_and_subs(test_folder) + assert not set(changes_to_be_committed) + assert set(untracked_files) == set(all_files) + + +def test_commit_change_file_again_and_add(test_folder, file3): + change_add_and_commit_file(file3, "") + file3.write_text("1") + add_function(file3) + file3_name = file3.relative_to(test_folder) + ( + changes_to_be_committed, + changes_not_staged_for_commit, + untracked_files, + ) = status_function() + assert set(changes_to_be_committed) == {file3_name} + assert not set(changes_not_staged_for_commit) + assert file3_name not in set(untracked_files) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..b3cf845 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,6 @@ +from project.utils import get_all_files_in_repository_and_subs + + +def test_get_all_files_in_repository(test_folder, empty_folder): + all_files = get_all_files_in_repository_and_subs(test_folder) + assert empty_folder.relative_to(test_folder) in set(all_files)