diff --git a/terrarium/dl_gitmanager/README.md b/terrarium/dl_gitmanager/README.md index faf30712d..7e729e35c 100644 --- a/terrarium/dl_gitmanager/README.md +++ b/terrarium/dl_gitmanager/README.md @@ -8,6 +8,9 @@ Tool for advanced/.custom git operations pip install -Ue ``` + +# dl-git + ## Common options ### --help @@ -29,7 +32,7 @@ dl-git --help Optional. Specify custom repository path ``` -dl-git --repo-path +dl-git --repo-path ``` By default CWD is used. @@ -62,3 +65,83 @@ By default they are printed relative to the repository root. The `--only-added-commits` option makes the tool inspect only commits that have been added in the head version. + + +# dl-cherry-farmer + +## Common options + +### --help + +Show the main help message + +```bash +dl-git --help +dl-git --h +``` + +The `--help` (`-h`) option can also be used for any command: +```bash +dl-git --help +``` + +### --repo-path + +Optional. Specify custom repository path + +``` +dl-cherry-farmer --repo-path +``` + +By default CWD is used. +The tool first looks for the repository at the given path, and then, if not found, +it starts a recursive search upwards, toward the path root. If no repository is found, an error is raised. +If the path is inside a submodule, then the submodule is considered to be the root repo. + +### --state-file + +Optional. Specify path to cherry-farmer's state file + +``` +dl-cherry-farmer --state-file +``` + +By default `./cherry_farmer.json` is used. + +## Commands + +### show + +```bash +dl-cherry-farmer show --src-branch --dst-branch +dl-cherry-farmer show --src-branch --dst-branch --new +dl-cherry-farmer show --src-branch --dst-branch --ignored +dl-cherry-farmer show --src-branch --dst-branch --picked +dl-cherry-farmer show --src-branch --dst-branch --all +``` + +Here `--src-branch` and `--dst-branch` are the names of the branches that you want to compare +With `--new` new (not picked and not ignored commits) will be shown. +With `--picked` picked commits will be shown. +With `--ignored` ignored commits will be shown. +With `-a` or `--all` all commits will be shown. + +### iter + +Iterate over all commits in diff and pick or ignore them interactively + +```bash +dl-cherry-farmer show --src-branch --dst-branch +dl-cherry-farmer show --src-branch --dst-branch --all +``` + +All options are the same as for the `show` command. + +### mark + +```bash +dl-cherry-farmer mark --commit --state +``` + +Here `commid_id` should be a valid commit ID, and `state` is the state to be set for this commit. +If `--state new` is specified, then the info about this commit is simply deleted from the saved state. diff --git a/terrarium/dl_gitmanager/dl_gitmanager/cherry_farmer.py b/terrarium/dl_gitmanager/dl_gitmanager/cherry_farmer.py new file mode 100644 index 000000000..cec5324be --- /dev/null +++ b/terrarium/dl_gitmanager/dl_gitmanager/cherry_farmer.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from contextlib import contextmanager +from enum import ( + Enum, + unique, +) +import json +from pathlib import Path +from typing import ( + AbstractSet, + Any, + Generator, + Iterable, + Iterator, + Optional, +) + +import attr +from git.objects.commit import Commit + +from dl_gitmanager.git_manager import GitManager + + +@unique +class CommitState(Enum): + new = "new" + picked = "picked" + ignored = "ignored" + + +@attr.s(frozen=True) +class CommitSavedStateItem: + commit_id: str = attr.ib(kw_only=True) + commit_state: CommitState = attr.ib(kw_only=True) + message: Optional[str] = attr.ib(kw_only=True) + timestamp: int = attr.ib(kw_only=True) + + +@attr.s(frozen=True) +class CommitRuntimeStateItem: + saved_state: CommitSavedStateItem = attr.ib(kw_only=True) + commit_message: str = attr.ib(kw_only=True) + + +@attr.s +class CommitPickerState: + _commit_state_items: dict[str, CommitSavedStateItem] = attr.ib(kw_only=True) + + def mark( + self, + commit_id: str, + state: CommitState, + message: Optional[str] = None, + timestamp: Optional[int] = None, + ) -> None: + if state == CommitState.new: + if commit_id in self._commit_state_items: + del self._commit_state_items[commit_id] + + else: + assert message is not None + assert timestamp is not None + commit_saved_state_item = CommitSavedStateItem( + commit_id=commit_id, + commit_state=state, + message=message, + timestamp=timestamp, + ) + self._commit_state_items[commit_id] = commit_saved_state_item + + def __iter__(self) -> Iterator[CommitSavedStateItem]: + return iter(sorted(self._commit_state_items.values(), key=lambda item: item.timestamp)) + + def __contains__(self, item: Any) -> bool: + if not isinstance(item, str): + raise TypeError(type(item)) + return item in self._commit_state_items + + def __getitem__(self, item: Any) -> CommitSavedStateItem: + if not isinstance(item, str): + raise TypeError(type(item)) + return self._commit_state_items[item] + + def clone(self) -> CommitPickerState: + return attr.evolve(self, commit_state_items=self._commit_state_items.copy()) + + +class CommitPickerStateIO: + def load_from_file(self, path: Path) -> CommitPickerState: + with open(path, "r") as state_file: + raw_list = json.load(state_file) + + commit_state_items: dict[str, CommitSavedStateItem] = {} + for raw_item in raw_list: + commit_saved_state_item = CommitSavedStateItem( + commit_id=raw_item["commit_id"], + timestamp=raw_item["timestamp"], + commit_state=CommitState(raw_item["commit_state"]), + message=raw_item["message"], + ) + commit_state_items[commit_saved_state_item.commit_id] = commit_saved_state_item + + return CommitPickerState(commit_state_items=commit_state_items) + + def save_to_file(self, path: Path, picker_state: CommitPickerState) -> None: + raw_list: list[dict] = [] + for commit_saved_state_item in picker_state: + raw_item = dict( + commit_id=commit_saved_state_item.commit_id, + timestamp=commit_saved_state_item.timestamp, + commit_state=commit_saved_state_item.commit_state.name, + message=commit_saved_state_item.message, + ) + raw_list.append(raw_item) + + with open(path, "w") as state_file: + json.dump(raw_list, state_file, sort_keys=True, indent=4) + + @contextmanager + def load_save_state(self, path: Path) -> Generator[CommitPickerState, None, None]: + picker_state = self.load_from_file(path=path) + original_state = picker_state.clone() + yield picker_state + if picker_state != original_state: + self.save_to_file(path=path, picker_state=picker_state) + + +@attr.s +class CherryFarmer: + _state: CommitPickerState = attr.ib(kw_only=True) + _git_manager: GitManager = attr.ib(kw_only=True) + + def _make_commit_runtime_state_item(self, commit_obj: Commit) -> CommitRuntimeStateItem: + commit_id = commit_obj.hexsha + commit_saved_state_item: CommitSavedStateItem + if commit_id in self._state: + commit_saved_state_item = self._state[commit_id] + else: + commit_saved_state_item = CommitSavedStateItem( + commit_id=commit_id, + commit_state=CommitState.new, + message="", + timestamp=commit_obj.committed_date, + ) + + return CommitRuntimeStateItem( + saved_state=commit_saved_state_item, + commit_message=commit_obj.message, + ) + + def iter_diff_commits( + self, + src_branch: str, + dst_branch: str, + states: AbstractSet[CommitState], + reverse: bool = False, + ) -> Generator[CommitRuntimeStateItem, None, None]: + commits: Iterable[Commit] + commits = self._git_manager.iter_commits(base=dst_branch, head=src_branch, only_missing_commits=True) + if not reverse: # Commits are in reverse order + commits = reversed(list(commits)) + + for commit_obj in commits: + commit_runtime_state_item = self._make_commit_runtime_state_item(commit_obj=commit_obj) + if commit_runtime_state_item.saved_state.commit_state in states: + yield commit_runtime_state_item + + def get_commit_state_item(self, commit_id: str) -> CommitRuntimeStateItem: + commit_obj = self._git_manager.get_commit_obj(commit_specifier=commit_id) + return self._make_commit_runtime_state_item(commit_obj=commit_obj) + + def mark( + self, + commit_id: str, + state: CommitState, + message: Optional[str] = None, + timestamp: Optional[int] = None, + ) -> None: + self._state.mark(commit_id=commit_id, state=state, message=message, timestamp=timestamp) + + def search_pick_suggestion( + self, + commit_id: str, + src_branch: str, + dst_branch: str, + ) -> Optional[CommitRuntimeStateItem]: + """Search for a commit that might be a cherry pick of another commit""" + # Get commits with reversed branch roles + commits = self._git_manager.iter_commits(base=src_branch, head=dst_branch, only_missing_commits=True) + for commit_obj in commits: + if commit_id in commit_obj.message: + return self._make_commit_runtime_state_item(commit_obj=commit_obj) + + return None diff --git a/terrarium/dl_gitmanager/dl_gitmanager/git_manager.py b/terrarium/dl_gitmanager/dl_gitmanager/git_manager.py index e6f44c055..13034441e 100644 --- a/terrarium/dl_gitmanager/dl_gitmanager/git_manager.py +++ b/terrarium/dl_gitmanager/dl_gitmanager/git_manager.py @@ -25,10 +25,10 @@ class GitManager: def get_root_path(self) -> Path: return Path(self.git_repo.working_tree_dir) - def _get_commit_obj(self, commit_specifier: str) -> Commit: + def get_commit_obj(self, commit_specifier: str) -> Commit: return self.git_repo.commit(commit_specifier) - def _iter_commits(self, base: str, head: str, only_missing_commits: bool) -> Iterable[Commit]: + def iter_commits(self, base: str, head: str, only_missing_commits: bool) -> Iterable[Commit]: if only_missing_commits: return self.git_repo.iter_commits(f"{base}..{head}") else: @@ -51,13 +51,13 @@ def _iter_range_diffs( only_missing_commits: bool = False, ) -> Generator[tuple[Path, Diff], None, None]: # Get commit objects - base_commit = self._get_commit_obj(base) - head_commit = self._get_commit_obj(head) + base_commit = self.get_commit_obj(base) + head_commit = self.get_commit_obj(head) base_path = self.get_root_path() if absolute else self.path_prefix # Iter commits: - for commit_obj in self._iter_commits(base=base, head=head, only_missing_commits=only_missing_commits): + for commit_obj in self.iter_commits(base=base, head=head, only_missing_commits=only_missing_commits): for diff_item in self._iter_diffs_from_commit(commit_obj): yield base_path, diff_item diff --git a/terrarium/dl_gitmanager/dl_gitmanager/scripts/cherry_farmer_cli.py b/terrarium/dl_gitmanager/dl_gitmanager/scripts/cherry_farmer_cli.py new file mode 100644 index 000000000..4454e3798 --- /dev/null +++ b/terrarium/dl_gitmanager/dl_gitmanager/scripts/cherry_farmer_cli.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys +import time +from typing import ( + Optional, + TextIO, +) + +import attr +from colorama import ( + Fore, + Style, +) +from colorama import init as colorama_init + +from dl_cli_tools.cli_base import CliToolBase +from dl_cli_tools.logging import setup_basic_logging +from dl_gitmanager.cherry_farmer import ( + CherryFarmer, + CommitPickerStateIO, + CommitRuntimeStateItem, + CommitState, +) +from dl_gitmanager.discovery import discover_repo +from dl_gitmanager.git_manager import GitManager + + +class StopIter(Exception): + pass + + +@attr.s +class GitManagerTool(CliToolBase): + input_text_io: TextIO = attr.ib(kw_only=True) + cherry_farmer: CherryFarmer = attr.ib(kw_only=True) + + @classmethod + def get_parser(cls) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="Git Cherry Farmer CLI") + parser.add_argument("--repo-path", type=Path, help="Main repo path", default=Path.cwd()) + parser.add_argument( + "--state-file", + type=Path, + help="Path to state file", + default=Path.cwd() / "cherry_pick.json", + ) + + # mix-in parsers + src_branch_parser = argparse.ArgumentParser(add_help=False) + src_branch_parser.add_argument("--src-branch", help="Source branch", required=True) + + dst_branch_parser = argparse.ArgumentParser(add_help=False) + dst_branch_parser.add_argument("--dst-branch", help="Destination branch", required=True) + + src_dst_branch_parser = argparse.ArgumentParser(add_help=False, parents=[src_branch_parser, dst_branch_parser]) + + commit_parser = argparse.ArgumentParser(add_help=False) + commit_parser.add_argument("--commit", help="Commit ID", required=True) + + message_parser = argparse.ArgumentParser(add_help=False) + message_parser.add_argument("-m", "--message", help="Message") + + state_parser = argparse.ArgumentParser(add_help=False) + state_parser.add_argument( + "--state", + choices=["picked", "ignored", "new"], + help="New state for the commit", + required=True, + ) + + # commands + subparsers = parser.add_subparsers(title="command", dest="command") + + picked_parser = argparse.ArgumentParser(add_help=False) + picked_parser.add_argument("--picked", action="store_true", help="Show picked commits") + + ignored_parser = argparse.ArgumentParser(add_help=False) + ignored_parser.add_argument("--ignored", action="store_true", help="Show ignored commits") + + new_parser = argparse.ArgumentParser(add_help=False) + new_parser.add_argument("--new", action="store_true", help="Show new commits") + + all_parser = argparse.ArgumentParser(add_help=False) + all_parser.add_argument("-a", "--all", action="store_true", help="Show all commits") + + commit_type_flag_parser = argparse.ArgumentParser( + add_help=False, + parents=[picked_parser, ignored_parser, new_parser, all_parser], + ) + + subparsers.add_parser( + "show", + parents=[src_dst_branch_parser, commit_type_flag_parser], + help="List file paths with changes given as commit range", + ) + subparsers.add_parser( + "mark", + parents=[commit_parser, state_parser, message_parser], + help="Mark commit as picked/ignored/new", + ) + subparsers.add_parser( + "iter", + parents=[src_dst_branch_parser, commit_type_flag_parser], + help="Iterate over commits and pick them interactively", + ) + + return parser + + def _get_states(self, picked: bool, ignored: bool, new: bool, all: bool) -> set[CommitState]: + result: set[CommitState] = set() + if picked or all: + result.add(CommitState.picked) + if ignored or all: + result.add(CommitState.ignored) + if new or all: + result.add(CommitState.new) + + if not result: + # no flags means "all" + return self._get_states(all=True) + + return result + + def _print_commit_state(self, commit_state_item: CommitRuntimeStateItem) -> None: + iso_time = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(commit_state_item.saved_state.timestamp)) + text = f""" +{Fore.RED}{commit_state_item.saved_state.commit_id}{Style.RESET_ALL}: + {iso_time} + Commit Message: {commit_state_item.commit_message.strip()} + Picker State: {commit_state_item.saved_state.commit_state.name} + Picker Message: {commit_state_item.saved_state.message} +""" + print(text) + + def _print_pick_suggestion(self, suggestion_item: CommitRuntimeStateItem) -> None: + iso_time = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(suggestion_item.saved_state.timestamp)) + text = f""" +{Fore.CYAN}Suggestion (found commit in dst): + {suggestion_item.saved_state.commit_id}: + {iso_time} - Commit Message: {suggestion_item.commit_message.strip()} +{Style.RESET_ALL}""" + print(text) + + def show( + self, src_branch: str, dst_branch: Optional[str], picked: bool, ignored: bool, new: bool, all: bool + ) -> None: + states = self._get_states(picked=picked, ignored=ignored, new=new, all=all) + for commit_state_item in self.cherry_farmer.iter_diff_commits( + src_branch=src_branch, + dst_branch=dst_branch, + states=states, + reverse=True, + ): + self._print_commit_state(commit_state_item) + + def mark(self, commit_id: str, state: str, message: str) -> None: + state_val = CommitState[state] + commit_state_item = self.cherry_farmer.get_commit_state_item(commit_id=commit_id) + self.cherry_farmer.mark( + commit_id=commit_id, + state=state_val, + message=message, + timestamp=commit_state_item.saved_state.timestamp, + ) + + def _prompt_mark_commit(self, commit_state_item: CommitRuntimeStateItem) -> tuple[CommitState, Optional[str]]: + while True: + raw_state = input(f'{Fore.GREEN}Mark as [picked|ignored|new] or "exit"> {Style.RESET_ALL}') + if raw_state == "exit": + raise StopIter + + try: + state = CommitState[raw_state] + except KeyError: + print(f"Invalid state: {raw_state}") + else: + break + + if state == CommitState.new: + message = "" + else: + message = input(f"{Fore.GREEN}Enter pick message > {Style.RESET_ALL}") + + return state, message + + def iter_( + self, src_branch: str, dst_branch: Optional[str], picked: bool, ignored: bool, new: bool, all: bool + ) -> None: + states = self._get_states(picked=picked, ignored=ignored, new=new, all=all) + for commit_state_item in self.cherry_farmer.iter_diff_commits( + src_branch=src_branch, + dst_branch=dst_branch, + states=states, + ): + self._print_commit_state(commit_state_item) + pick_suggestion_item = self.cherry_farmer.search_pick_suggestion( + src_branch=src_branch, + dst_branch=dst_branch, + commit_id=commit_state_item.saved_state.commit_id, + ) + if pick_suggestion_item is not None: + self._print_pick_suggestion(pick_suggestion_item) + try: + state, message = self._prompt_mark_commit(commit_state_item=commit_state_item) + except StopIter: + return + + self.cherry_farmer.mark( + commit_id=commit_state_item.saved_state.commit_id, + state=state, + message=message, + timestamp=commit_state_item.saved_state.timestamp, + ) + + @classmethod + def initialize(cls, cherry_farmer: CherryFarmer) -> GitManagerTool: + tool = cls(input_text_io=sys.stdin, cherry_farmer=cherry_farmer) + return tool + + @classmethod + def run_parsed_args(cls, args: argparse.Namespace) -> None: + colorama_init() + + git_repo = discover_repo(repo_path=args.repo_path) + state_io = CommitPickerStateIO() + git_manager = GitManager(git_repo=git_repo) + + with state_io.load_save_state(path=args.state_file) as picker_state: + cherry_farmer = CherryFarmer(git_manager=git_manager, state=picker_state) + tool = cls.initialize(cherry_farmer=cherry_farmer) + match args.command: + case "show": + tool.show( + src_branch=args.src_branch, + dst_branch=args.dst_branch, + picked=args.picked, + ignored=args.ignored, + new=args.new, + all=args.all, + ) + case "mark": + tool.mark( + commit_id=args.commit, + state=args.state, + message=args.message, + ) + case "iter": + tool.iter_( + src_branch=args.src_branch, + dst_branch=args.dst_branch, + picked=args.picked, + ignored=args.ignored, + new=args.new, + all=args.all, + ) + case _: + raise RuntimeError(f"Got unknown command: {args.command}") + + +def main() -> None: + setup_basic_logging() + GitManagerTool.run(sys.argv[1:]) + + +if __name__ == "__main__": + main() diff --git a/terrarium/dl_gitmanager/pyproject.toml b/terrarium/dl_gitmanager/pyproject.toml index bd85d2f91..e1caf011e 100644 --- a/terrarium/dl_gitmanager/pyproject.toml +++ b/terrarium/dl_gitmanager/pyproject.toml @@ -13,10 +13,12 @@ readme = "README.md" attrs = ">=22.2.0" python = ">=3.10, <3.12" gitpython = ">=3.1.37" +colorama = ">=0.4.3" datalens-cli-tools = {path = "../dl_cli_tools"} [tool.poetry.group.tests.dependencies] pytest = ">=7.2.2" + [build-system] build-backend = "poetry.core.masonry.api" requires = [ @@ -25,6 +27,7 @@ requires = [ [tool.poetry.scripts] dl-git = "dl_gitmanager.scripts.gitmanager_cli:main" +dl-cherry-farmer = "dl_gitmanager.scripts.cherry_farmer_cli:main" [tool.pytest.ini_options] minversion = "6.0"