Skip to content

Commit

Permalink
Add git blame copy command
Browse files Browse the repository at this point in the history
  • Loading branch information
yalef committed Nov 23, 2023
1 parent c317440 commit 6c8abb6
Show file tree
Hide file tree
Showing 4 changed files with 244 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ We follow [Semantic Versions](https://semver.org/).
## unreleased

- Improve `pre-commit.run-hooks` command with `params`
- Add `git.blame-copy` command

## 0.9.1

Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,23 @@ Settings:

Clone repo or pull latest changes to specified repo

#### git.blame-copy

Command for creating copies of a file with git blame history saving.

Original script written in bash:
https://dev.to/deckstar/how-to-git-copy-copying-files-while-keeping-git-history-1c9j

Usage:
```shell
inv git.blame-copy <path to original file> <path to copy>,<path to copy>...
```

Settings:

* `copy_commit_template` template for commits created during command workflow
* `copy_init_message_template` template for init message printed at command start

### pre-commit

#### pre-commit.install
Expand Down
11 changes: 11 additions & 0 deletions saritasa_invocations/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ class GitSettings:

merge_ff: str = "false"
pull_ff: str = "only"
copy_commit_template: str = (
"[automated-commit]: {action}\n\n"
"copy: {original_path}\n"
"to:\n* {destination_paths}\n\n"
"{jira_task}"
)
copy_init_message_template: str = (
"Copy {original_path} to:\n"
"* {destination_paths}\n\n"
"Count of created commits: {commits_count}"
)


@dataclasses.dataclass
Expand Down
215 changes: 215 additions & 0 deletions saritasa_invocations/git.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import pathlib

import invoke
import collections.abc
import re

from . import _config, pre_commit, printing

Expand Down Expand Up @@ -48,3 +50,216 @@ def clone_repo(
printing.print_success(f"Pulling changes for {repo_link}...")
with context.cd(repo_path):
context.run("git pull")


@invoke.task
def blame_copy(
context: invoke.Context,
original_path: str,
destination_paths: str,
) -> None:
"""Copy file from original path to destination paths with saving blame.
If destination path is file,
then data will be copied in it.
If destination path is directory,
then data will be copied in provided directory with original name.
How script works:
1) Remember current HEAD state
2) For each copy path:
move file to copy path, restore file using `checkout`,
remember result commits
3) Restore state of branch
4) Move file to temp file
5) Merge copy commits to branch
6) Move file to it's original path from temp file
Count of created commits:
N + 3, where N - is count of copies,
3 - 1 commit to put original file to temp file
1 commit to merge commits with creation of copy files
1 commit to put data from temp file to original file back.
"""
config = _config.Config.from_context(context)
destination_paths = _split_destination_paths(destination_paths)
_validate_paths(original_path, destination_paths)
printing.print_success(config.git.copy_init_message_template.format(
original_path=original_path,
destination_paths="\n* ".join(destination_paths),
commits_count=len(destination_paths) + 3,
))

to_continue = input("Continue? (Y/N)\n")
if to_continue.lower() != "y":
raise invoke.Exit(code=1)

# formatted commit template with only space for action
formatted_commit_template = config.git.copy_commit_template.format(
action="{action}",
original_path=original_path,
destination_paths="\n* ".join(destination_paths),
jira_task=_build_task_string(context=context),
)

# temp file to save original file
temp_file = context.run(
f"mktemp ./{destination_paths[0]}.XXXXXX",
).stdout.rstrip()

# current HEAD state
printing.print_success("Get current HEAD sha")
root_commit = context.run("git rev-parse HEAD").stdout.rstrip()
printing.print_success("Create copies")

# create copies with blame of original
copy_commits = _copy_files(
context=context,
original_path=original_path,
destination_paths=destination_paths,
root_commit=root_commit,
commit_template=formatted_commit_template,
)

# put original file to temp copy
printing.print_success("Restore branch to original state")
context.run("git reset --hard HEAD^")
printing.print_success(f"Move {original_path} to temp file")
_move_file(
context=context,
from_path=original_path,
to_path=temp_file,
options=["-f"],
message=formatted_commit_template.format(
action=f"put {original_path} to temp file",
),
)

# merge copy commits
printing.print_success("Merge copy commits")
_merge_commits(
context=context,
commits=copy_commits,
message=formatted_commit_template.format(action="merge"),
)

# move original file back
printing.print_success(f"Move data from temp file to {original_path}")
_move_file(
context=context,
from_path=temp_file,
to_path=original_path,
message=formatted_commit_template.format(
action=f"put temp file data to {original_path}",
),
)
printing.print_success("Success")


def _split_destination_paths(
destination_paths: str,
) -> list[str]:
"""Split destination path to sequence and strip trailing symbols."""
return [path.strip() for path in destination_paths.split(",")]


def _validate_paths(
original_path: str,
destination_paths: collections.abc.Sequence[str],
) -> None:
"""Validate provided paths exists."""
error_messages = []
if not pathlib.Path(original_path).exists():
error_messages.append(
f"{original_path} not found.",
)
for destination in destination_paths:
dirname = pathlib.Path(destination).parent
if dirname and not pathlib.Path(dirname).exists():
error_messages.append(f"{dirname} not found.")
if error_messages:
printing.print_error(
"\n".join(error_messages)
)
raise invoke.Exit(
message="Failed to validate provided paths.",
code=1,
)


def _merge_commits(
context: invoke.Context,
commits: collections.abc.Sequence[str],
message: str,
) -> None:
"""Merge passed commits."""
context.run(f"git merge {' '.join(commits)} -m '{message}'")


def _move_file(
context: invoke.Context,
from_path: str,
to_path: str,
message: str,
options: collections.abc.Sequence[str] = tuple(),
) -> None:
"""Move `first_file `to `second_file` path using git."""
context.run(f"git mv {' '.join(options)} {from_path} {to_path}")
context.run(f"git commit --no-verify -n -m '{message}'")


def _copy_files(
context: invoke.Context,
original_path: str,
destination_paths: collections.abc.Sequence[str],
root_commit: str,
commit_template: str,
) -> list[str]:
"""Copy file from `original_path` to `destination_paths` using git.
Return commits related to each copy.
"""
commits = []
for path in destination_paths:
context.run(f"git reset --soft {root_commit}")
context.run(f"git checkout {root_commit} {original_path}")
context.run(f"git mv -f {original_path} {path}")

commit_message = commit_template.format(
action=f"create {path}",
)
context.run(f"git commit --no-verify -n -m '{commit_message}'")
new_commit = context.run("git rev-parse HEAD").stdout.rstrip()
commits.append(new_commit)
return commits


def _build_task_string(
context: invoke.Context,
) -> str:
"""Build task string.
Build string with following format: Task: <jira-task-id>
If current git branch has no task id, then empty string will return.
"""
task_id = _get_jira_task_from_current_branch(context=context)
if not task_id:
return ""
return f"Task: {task_id}"


def _get_jira_task_from_current_branch(
context: invoke.Context,
) -> str:
"""Get jira task from current branch.
If branch has no task, then empty string will return.
"""
current_branch = context.run("git branch --show-current").stdout.rstrip()
match = re.search(r"\w+\/(\w+-\d+)", current_branch)
if match is None:
return ""
task = match.group(1)
return task

0 comments on commit 6c8abb6

Please sign in to comment.