Skip to content

Commit

Permalink
Implemented cherry-farmer tool for managing cherry-picked commits
Browse files Browse the repository at this point in the history
  • Loading branch information
altvod committed Nov 28, 2023
1 parent 0dbbd09 commit 0df0987
Show file tree
Hide file tree
Showing 5 changed files with 556 additions and 6 deletions.
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

0 comments on commit 0df0987

Please sign in to comment.