Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented cherry-farmer tool for managing cherry-picked commits #129

Merged
merged 1 commit into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion terrarium/dl_gitmanager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Tool for advanced/.custom git operations
pip install -Ue <path-to-package>
```


# dl-git

## Common options

### --help
Expand All @@ -29,7 +32,7 @@ dl-git <command> --help
Optional. Specify custom repository path

```
dl-git --repo-path <command> <args>
dl-git --repo-path <path> <command> <args>
```

By default CWD is used.
Expand Down Expand Up @@ -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 <command> --help
```

### --repo-path

Optional. Specify custom repository path

```
dl-cherry-farmer --repo-path <command> <args>
```

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 <path> <command> <args>
```

By default `./cherry_farmer.json` is used.

## Commands

### show

```bash
dl-cherry-farmer show --src-branch <branch> --dst-branch <branch>
dl-cherry-farmer show --src-branch <branch> --dst-branch <branch> --new
dl-cherry-farmer show --src-branch <branch> --dst-branch <branch> --ignored
dl-cherry-farmer show --src-branch <branch> --dst-branch <branch> --picked
dl-cherry-farmer show --src-branch <branch> --dst-branch <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 <branch> --dst-branch <branch>
dl-cherry-farmer show --src-branch <branch> --dst-branch <branch> --all
```

All options are the same as for the `show` command.

### mark

```bash
dl-cherry-farmer mark --commit <commit_id> --state <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.
195 changes: 195 additions & 0 deletions terrarium/dl_gitmanager/dl_gitmanager/cherry_farmer.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 5 additions & 5 deletions terrarium/dl_gitmanager/dl_gitmanager/git_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
Loading