diff --git a/git_tool/__main__.py b/git_tool/__main__.py deleted file mode 100644 index 69b1c5e..0000000 --- a/git_tool/__main__.py +++ /dev/null @@ -1,28 +0,0 @@ -import typer - -from git_tool.ci.subcommands.feature_add import feature_add_by_add -from git_tool.ci.subcommands.feature_add_from_staged import features_from_staging_area -from git_tool.ci.subcommands.feature_blame import feature_blame -from git_tool.ci.subcommands.feature_commit import feature_commit -from git_tool.ci.subcommands.feature_commit_msg import feature_commit_msg -from git_tool.ci.subcommands.feature_commits import app as feature_commits -from git_tool.ci.subcommands.feature_info import inspect_feature -from git_tool.ci.subcommands.feature_info_all import all_feature_info -from git_tool.ci.subcommands.feature_pre_commit import feature_pre_commit -from git_tool.ci.subcommands.feature_status import feature_status - - -app = typer.Typer(name="feature", no_args_is_help=True) # "git feature --help" does not work, but "git-feature --help" does -app.command(name="add", help="Stage files and associate them with the provided features.")(feature_add_by_add) -app.command(name="add-from-staged", help="Associate staged files with features.")(features_from_staging_area) -app.command(name="blame", help="Display features associated with file lines.")(feature_blame) -app.command(name="commit", help="Associate an existing commit with one or more features.")(feature_commit) -app.command(name="commit-msg", help="Generate feature information for the commit message.")(feature_commit_msg) -app.add_typer(feature_commits, name="commits", help="Use with the subcommand 'list' or 'missing' to show commits with or without associated features.") -app.command(name="info", help="Show information of a specific feature.")(inspect_feature) -app.command(name="info-all", help="List all available features in the project.")(all_feature_info) -app.command(name="pre-commit", help="Check if all staged changes are properly associated with features.")(feature_pre_commit) -app.command(name="status", help="Display unstaged and staged changes with associated features.")(feature_status) - - -app() diff --git a/git_tool/ci/subcommands/feature_add.py b/git_tool/ci/subcommands/feature_add.py index 26e65f4..fedfb1d 100644 --- a/git_tool/ci/subcommands/feature_add.py +++ b/git_tool/ci/subcommands/feature_add.py @@ -14,7 +14,11 @@ app = typer.Typer(no_args_is_help=True) -@app.command("add", help="Stage files and associate them with the provided features.", no_args_is_help=True) +@app.command( + "add", + help="Stage files and associate them with the provided features.", + no_args_is_help=True, +) def feature_add_by_add( feature_names: list[str] = typer.Argument( None, help="List of feature names to associate with the staged files" @@ -52,7 +56,6 @@ def feature_add_by_add( typer.echo("No features provided.", err=True) - def stage_files(selected_files: list[str]) -> bool: """ Stage all selected files and return whether the staging area has changed. diff --git a/git_tool/ci/subcommands/feature_add_from_staged.py b/git_tool/ci/subcommands/feature_add_from_staged.py index 66f528b..9bfeef5 100644 --- a/git_tool/ci/subcommands/feature_add_from_staged.py +++ b/git_tool/ci/subcommands/feature_add_from_staged.py @@ -13,7 +13,9 @@ app = typer.Typer() -@app.command(name="add-from-staged", help="Associate staged files with features.") +@app.command( + name="add-from-staged", help="Associate staged files with features." +) def features_from_staging_area(): """ Use the staged files to add feature information. @@ -53,4 +55,4 @@ def read_features_from_staged(type: str = "Union") -> list[str]: "Invalid type specified. Use 'Union' or 'Intersection'." ) - return list(combined_features) \ No newline at end of file + return list(combined_features) diff --git a/git_tool/ci/subcommands/feature_blame.py b/git_tool/ci/subcommands/feature_blame.py index 900ef31..e9562ca 100644 --- a/git_tool/ci/subcommands/feature_blame.py +++ b/git_tool/ci/subcommands/feature_blame.py @@ -3,7 +3,9 @@ import typer from git import Repo -from git_tool.feature_data.read_feature_data.parse_data import get_features_touched_by_commit +from git_tool.feature_data.read_feature_data.parse_data import ( + get_features_touched_by_commit, +) from git_tool.feature_data.models_and_context.repo_context import ( repo_context, ) @@ -45,7 +47,9 @@ def get_line_to_blame_mapping( return line_to_blame -def get_commit_to_features_mapping(line_to_commit: dict[int, tuple[str, str]]) -> dict[str, str]: +def get_commit_to_features_mapping( + line_to_commit: dict[int, tuple[str, str]], +) -> dict[str, str]: """ Returns a mapping of commit hashes to features. """ @@ -66,7 +70,9 @@ def get_line_to_features_mapping( Returns a mapping of line numbers to features. """ # Get the commit for each line using 'git blame' - line_to_blame = get_line_to_blame_mapping(repo, file_path, start_line, end_line) + line_to_blame = get_line_to_blame_mapping( + repo, file_path, start_line, end_line + ) # for debugging: print("Step 1: ", line_to_blame) # Get the features for each commit @@ -95,19 +101,28 @@ def print_feature_blame_output( line_to_features, line_to_blame = mappings # Get the max width of feature strings for alignment max_feature_width = max( - (len(line_to_features.get(commit, "UNKNOWN")) for commit in line_to_features.values()), + ( + len(line_to_features.get(commit, "UNKNOWN")) + for commit in line_to_features.values() + ), default=15, ) for i in range(start_line, end_line + 1): - line = lines[i - 1] # Adjust because list is 0-indexed, but line numbers start from 1 + line = lines[ + i - 1 + ] # Adjust because list is 0-indexed, but line numbers start from 1 commit_hash, blame_text = line_to_blame.get(i) blame_text = blame_text.replace("(", "", 1) feature = line_to_features.get(i, "UNKNOWN") typer.echo(f"{feature:<15} ({commit_hash} {blame_text}") -@app.command(help="Display features associated with file lines.", no_args_is_help=True, name=None) +@app.command( + help="Display features associated with file lines.", + no_args_is_help=True, + name=None, +) def feature_blame( filename: str = typer.Argument( ..., help="The file to display feature blame for." diff --git a/git_tool/ci/subcommands/feature_commit.py b/git_tool/ci/subcommands/feature_commit.py index 2e142eb..6e637a1 100644 --- a/git_tool/ci/subcommands/feature_commit.py +++ b/git_tool/ci/subcommands/feature_commit.py @@ -12,7 +12,10 @@ read_staged_featureset, reset_staged_featureset, ) -from git_tool.feature_data.models_and_context.repo_context import repo_context, sync_feature_branch +from git_tool.feature_data.models_and_context.repo_context import ( + repo_context, + sync_feature_branch, +) app = typer.Typer( @@ -36,7 +39,10 @@ def feature_commit( help="Manually specify feature names. Each feature-name needs to be prefixed with --features \ If this option is provided, staged feature information will be ignored.", ), - upload: bool = typer.Option(True, help="Set whether feature information are also directly uploaded to remote.") + upload: bool = typer.Option( + True, + help="Set whether feature information are also directly uploaded to remote.", + ), ): """ Associate feature information with a regular git commit. @@ -76,13 +82,12 @@ def feature_commit( # Add the fact to the metadata branch add_fact_to_metadata_branch(fact=feature_fact, commit_ref=commit_obj) typer.echo(f"Features {features} assigned to {commit_id}") - + if upload: sync_feature_branch() # typer.echo("Step 4: Cleanup all information/ internal state stuff") reset_staged_featureset() - - + # typer.echo("Feature commit process completed successfully.") diff --git a/git_tool/ci/subcommands/feature_info.py b/git_tool/ci/subcommands/feature_info.py index 70f3a27..5595702 100644 --- a/git_tool/ci/subcommands/feature_info.py +++ b/git_tool/ci/subcommands/feature_info.py @@ -17,7 +17,10 @@ def print_list_w_indent(stuff: list, indent: int = 1) -> None: typer.echo("\t" * indent + item) -app = typer.Typer(help="Displaying feature information for the entire git repo", no_args_is_help=True) +app = typer.Typer( + help="Displaying feature information for the entire git repo", + no_args_is_help=True, +) # TODO sollte zu git feature-status diff --git a/git_tool/ci/subcommands/feature_info_all.py b/git_tool/ci/subcommands/feature_info_all.py index 4bd86c5..d20a523 100644 --- a/git_tool/ci/subcommands/feature_info_all.py +++ b/git_tool/ci/subcommands/feature_info_all.py @@ -19,4 +19,4 @@ def all_feature_info(): def print_list_w_indent(stuff: list, indent: int = 1) -> None: for item in stuff: - typer.echo("\t" * indent + item) \ No newline at end of file + typer.echo("\t" * indent + item) diff --git a/git_tool/ci/subcommands/feature_pre_commit.py b/git_tool/ci/subcommands/feature_pre_commit.py index 34847b0..4004960 100644 --- a/git_tool/ci/subcommands/feature_pre_commit.py +++ b/git_tool/ci/subcommands/feature_pre_commit.py @@ -24,7 +24,9 @@ def feature_pre_commit(): staged_features = read_staged_featureset() if not staged_features: - typer.echo("Warning: No features associated with the staged changes. Remember to use git feature-commit to add these information") + typer.echo( + "Warning: No features associated with the staged changes. Remember to use git feature-commit to add these information" + ) raise typer.Exit(code=0) typer.echo("Pre-commit checks passed.") diff --git a/git_tool/ci/subcommands/feature_status.py b/git_tool/ci/subcommands/feature_status.py index 7800182..2025845 100644 --- a/git_tool/ci/subcommands/feature_status.py +++ b/git_tool/ci/subcommands/feature_status.py @@ -1,4 +1,5 @@ from collections import defaultdict + import typer from git_tool.feature_data.git_status_per_feature import ( @@ -23,7 +24,7 @@ def feature_status( help: bool = typer.Option( None, "--help", "-h", is_eager=True, help="Show this message and exit." - ) + ), ): """ Displays the current status of files in the working directory, showing staged, unstaged, and untracked changes along with their associated features. diff --git a/git_tool/ci/subcommands/variant_derive.py b/git_tool/ci/subcommands/variant_derive.py new file mode 100644 index 0000000..dd7395e --- /dev/null +++ b/git_tool/ci/subcommands/variant_derive.py @@ -0,0 +1,185 @@ +import os +import re +import tempfile +from pathlib import PurePosixPath + +import typer +from git import Blob, Repo, Tree + +from git_tool.feature_data.models_and_context.repo_context import ( + FEATURE_BRANCH_NAME, + repo_context, +) + + +def derive_variant( + name: str = typer.Argument(..., help="Name of the variant"), + features: list[str] = typer.Option( + ..., + "--features", + "-f", + help="One or more features to include", + ), + refresh: bool = typer.Option( + False, "--refresh", "-r", help="Refresh the variant" + ), +): + """Derive a variant from the provided feature set.""" + with repo_context() as repo: + if repo.is_dirty(untracked_files=True): + raise RuntimeError( + "Repository is not clean. Commit or stash your changes first." + ) + + ref_name = f"refs/heads/variant/{name}" + target_features: set[str] = set(features) + + existing_ref = None + try: + existing_ref = repo.commit(ref_name) + except: + pass + + if existing_ref is not None and not refresh: + raise RuntimeError( + f"Variant branch '{ref_name}' already exists. Pass --refresh to overwrite." + ) + + # Load metadata tree once to avoid re-resolving per commit + metadata_tree = repo.commit(f"refs/heads/{FEATURE_BRANCH_NAME}").tree + + head_tree = repo.head.commit.tree + new_tree_oid = build_variant_tree( + repo, head_tree, PurePosixPath(""), target_features, metadata_tree + ) + + commit_msg = f"Variant derivation with features: '{features}'" + new_commit_oid = repo.git.commit_tree( + new_tree_oid, m=commit_msg + ).strip() + repo.git.update_ref(ref_name, new_commit_oid) + + +def build_variant_tree( + repo: Repo, + tree: Tree, + base_path: PurePosixPath, + target_features: set[str], + metadata_tree: Tree, +) -> str: + mktree_lines: list[str] = [] + + for item in tree: + child_path = base_path / item.name + + if item.type == "blob": + filtered = process_blob( + repo, item, child_path, target_features, metadata_tree + ) + # Skip committing the file if empty + if not filtered or not filtered.strip(): + continue + + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write(filtered.encode()) + tmp_path = tmp.name + + try: + new_sha = repo.git.hash_object("-w", tmp_path) + finally: + os.unlink(tmp_path) + + mktree_lines.append(f"{item.mode:06o} blob {new_sha}\t{item.name}") + + elif item.type == "tree": + subtree_sha = build_variant_tree( + repo, item, child_path, target_features, metadata_tree + ) + mktree_lines.append(f"040000 tree {subtree_sha}\t{item.name}") + + else: + mktree_lines.append( + f"{item.mode:06o} {item.type} {item.hexsha}\t{item.name}" + ) + + with tempfile.NamedTemporaryFile( + mode="w", delete=False, suffix=".mktree" + ) as tmp: + tmp.write("\n".join(mktree_lines)) + tmp_path = tmp.name + + try: + with open(tmp_path) as f: + new_tree_sha = repo.git.mktree(istream=f) + finally: + os.unlink(tmp_path) + + return new_tree_sha.strip() + + +def process_blob( + repo: Repo, + blob: Blob, + path: PurePosixPath, + target_features: set[str], + metadata_tree: Tree, +) -> str: + raw = blob.data_stream.read().decode("utf-8", errors="replace") + lines = raw.splitlines(keepends=True) + + if not lines: + return "" + + blame_output = repo.git.blame("HEAD", "--porcelain", "--", str(path)) + line_to_sha = _parse_blame_porcelain(blame_output) + + # Per-commit feature membership cache + commit_cache: dict[str, bool] = {} + + result: list[str] = [] + for i, line in enumerate(lines): + line_no = i + 1 + sha = line_to_sha.get(line_no) + + if sha is None: + raise RuntimeError("Blame info missing for line!") + + if sha not in commit_cache: + commit_cache[sha] = _commit_has_feature( + sha, target_features, metadata_tree + ) + + if commit_cache[sha]: + result.append(line) + else: + result.append("") + + return "".join(result) + + +def _parse_blame_porcelain(porcelain: str) -> dict[int, str]: + line_to_sha: dict[int, str] = {} + lines = porcelain.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + # Blame header starts with a 40-char hex + if len(line) >= 40 and re.match(r"^[0-9a-f]{40}", line): + parts = line.split() + sha = parts[0] + result_line = int(parts[2]) + line_to_sha[result_line] = sha + i += 1 + return line_to_sha + + +def _commit_has_feature( + sha: str, target_features: set[str], metadata_tree: Tree +) -> bool: + for feature in target_features: + try: + metadata_tree[feature][sha] + return True + except KeyError: + continue + return False diff --git a/git_tool/ci/subcommands/variant_list.py b/git_tool/ci/subcommands/variant_list.py new file mode 100644 index 0000000..f12b2ca --- /dev/null +++ b/git_tool/ci/subcommands/variant_list.py @@ -0,0 +1,11 @@ +from git_tool.feature_data.models_and_context.repo_context import repo_context + + +def list_variant(): + """List all the variants present in the repo""" + + with repo_context() as repo: + for ref in repo.refs: + if ref.path.startswith("refs/heads/variant/"): + variant = ref.path.replace("refs/heads/variant/", "") + print(variant) diff --git a/git_tool/feature_data/add_feature_data/add_data.py b/git_tool/feature_data/add_feature_data/add_data.py index 2268c8c..4ca042e 100644 --- a/git_tool/feature_data/add_feature_data/add_data.py +++ b/git_tool/feature_data/add_feature_data/add_data.py @@ -46,7 +46,7 @@ def generate_fact_file_path(fact: FeatureFactModel) -> list[Path]: Path(uuid) .joinpath(Path(fact.commit)) .joinpath( - f'{fact.date.isoformat(timespec="minutes").replace(":", "-")}-{sha1_hash[:7]}' + f"{fact.date.isoformat(timespec='minutes').replace(':', '-')}-{sha1_hash[:7]}" ) .as_posix() for uuid in feature_uuids diff --git a/git_tool/feature_data/analyze_feature_data/feature_utils.py b/git_tool/feature_data/analyze_feature_data/feature_utils.py index 8a627dc..c415c80 100644 --- a/git_tool/feature_data/analyze_feature_data/feature_utils.py +++ b/git_tool/feature_data/analyze_feature_data/feature_utils.py @@ -96,11 +96,13 @@ def get_uuid_for_featurename(name: str) -> uuid.UUID: # TODO this is not implemented correctly return name + # Usages: FEATURE INFO def get_current_branchname() -> str: with repo_context() as repo: return repo.active_branch + # Usages: FEATURE INFO def get_commits_for_feature_on_other_branches( feature_commits: set[str], @@ -166,6 +168,7 @@ def get_all_features() -> list[str]: folders = folder_string.splitlines() return folders + # Usages: FEATURE COMMITS def get_commits_with_feature() -> list[str]: """ diff --git a/git_tool/feature_data/file_based_git_info.py b/git_tool/feature_data/file_based_git_info.py index 4fadb99..14095ad 100644 --- a/git_tool/feature_data/file_based_git_info.py +++ b/git_tool/feature_data/file_based_git_info.py @@ -1,5 +1,5 @@ """ -Functions to retrieve git information for a specific file rather than starting with +Functions to retrieve git information for a specific file rather than starting with a list of features or commits """ diff --git a/git_tool/feature_data/git_status_per_feature.py b/git_tool/feature_data/git_status_per_feature.py index 4c195f8..9f18c4e 100644 --- a/git_tool/feature_data/git_status_per_feature.py +++ b/git_tool/feature_data/git_status_per_feature.py @@ -23,6 +23,7 @@ class GitChanges(TypedDict): GitStatusEntry = namedtuple("GitStatusEntry", ["status", "file_path"]) + # Usages: FEATURE ADD, ADD-FROM-STAGED, PRE-COMMIT, STATUS def get_files_by_git_change() -> GitChanges: """ @@ -64,6 +65,7 @@ def find_annotations_for_file(file: str): """ raise NotImplementedError + # Usage: FEATURE ADD-FROM-STAGED, BLAME, STATUS def get_features_for_file( file_path: str, use_annotations: bool = False @@ -102,9 +104,9 @@ def get_features_for_file( features.append(feature_name) return features + # Usages: FEATURE INFO, FEATURE STATUS def get_commits_for_feature(feature_uuid: str) -> list[Commit]: - with repo_context() as repo: output = repo.git.ls_tree( "-d", "--name-only", f"{FEATURE_BRANCH_NAME}:{feature_uuid}" @@ -123,15 +125,17 @@ def commit_in_feature_folder(commit: str, feature_folder: str) -> bool: Returns: bool: True if the commit is present in the feature folder, False otherwise. """ - assert isinstance( - commit, str - ), f"Expected commit to be a string, but got {type(commit).__name__}" - assert isinstance( - feature_folder, str - ), f"Expected feature_folder to be a string, but got {type(feature_folder).__name__}" + assert isinstance(commit, str), ( + f"Expected commit to be a string, but got {type(commit).__name__}" + ) + assert isinstance(feature_folder, str), ( + f"Expected feature_folder to be a string, but got {type(feature_folder).__name__}" + ) with repo_context() as repo: - commit_obj =repo.commit(commit) - result = commit_obj.hexsha in [x.hexsha for x in get_commits_for_feature(feature_uuid=feature_folder)] + commit_obj = repo.commit(commit) + result = commit_obj.hexsha in [ + x.hexsha for x in get_commits_for_feature(feature_uuid=feature_folder) + ] return result diff --git a/git_tool/feature_data/models_and_context/feature_state.py b/git_tool/feature_data/models_and_context/feature_state.py index 0f0f131..172680d 100644 --- a/git_tool/feature_data/models_and_context/feature_state.py +++ b/git_tool/feature_data/models_and_context/feature_state.py @@ -8,19 +8,27 @@ from git_tool.feature_data.models_and_context.repo_context import repo_context - -def get_feature_file()-> Path: +def get_feature_file() -> Path: """ Depending on whether the repo is a git worktree or a usual git repo, the resolution of the .git folder works differently """ with repo_context() as repo: - git_repo= Path(repo.git.rev_parse("--show-toplevel")).resolve().joinpath(".git") + git_repo = ( + Path(repo.git.rev_parse("--show-toplevel")) + .resolve() + .joinpath(".git") + ) if git_repo.is_file(): - return Path(repo.git.rev_parse("--show-toplevel")).resolve().joinpath(".FEATUREINFO") + return ( + Path(repo.git.rev_parse("--show-toplevel")) + .resolve() + .joinpath(".FEATUREINFO") + ) else: return git_repo.joinpath("FEATUREINFO") + def read_staged_featureset() -> List[str]: """ Read the list of staged features from the FEATUREINFO file. @@ -35,6 +43,7 @@ def read_staged_featureset() -> List[str]: features = set(line.strip() for line in f.readlines()) return list(features) + # Usage: FEATURE ADD, ADD-FROM-STAGED def write_staged_featureset(features: List[str]): """ @@ -49,10 +58,11 @@ def write_staged_featureset(features: List[str]): for feature in features: f.write(f"{feature}\n") + def reset_staged_featureset(): """ Reset the FEATUREINFO file by clearing its content. """ feature_file = get_feature_file() if feature_file.exists(): - feature_file.unlink() \ No newline at end of file + feature_file.unlink() diff --git a/git_tool/feature_data/models_and_context/repo_context.py b/git_tool/feature_data/models_and_context/repo_context.py index a4ae7b9..b92c0fe 100644 --- a/git_tool/feature_data/models_and_context/repo_context.py +++ b/git_tool/feature_data/models_and_context/repo_context.py @@ -74,21 +74,26 @@ def create_empty_branch(branch_name: str, repo: git.Repo) -> str: return fast_import_script + HOME_DIR = os.path.expanduser("~") TIMESTAMP_FILE = os.path.join(HOME_DIR, ".feature_branch_timestamp.txt") + + def get_last_execution_time(): if os.path.exists(TIMESTAMP_FILE): - with open(TIMESTAMP_FILE, 'r') as f: + with open(TIMESTAMP_FILE, "r") as f: try: return datetime.fromisoformat(f.read().strip()) except ValueError: - return None + return None return None + def update_last_execution_time(): - with open(TIMESTAMP_FILE, 'w') as f: + with open(TIMESTAMP_FILE, "w") as f: f.write(datetime.now().isoformat()) - + + def ensure_feature_branch(func): """ Decorator to ensure that the feature branch is created if it does not exist. @@ -101,14 +106,22 @@ def wrapper(*args, **kwargs): repo = git.Repo(REPO_PATH) current_time = datetime.now() # print("Executing ensure feautre branch") - if last_execution_time is None or ((current_time- last_execution_time) > timedelta(minutes=5)): + if last_execution_time is None or ( + (current_time - last_execution_time) > timedelta(minutes=5) + ): update_last_execution_time() if FEATURE_BRANCH_NAME not in repo.heads: try: - repo.git.branch(FEATURE_BRANCH_NAME, f"origin/{FEATURE_BRANCH_NAME}") - typer.echo(f"Branch {FEATURE_BRANCH_NAME} created locally from origin.") + repo.git.branch( + FEATURE_BRANCH_NAME, f"origin/{FEATURE_BRANCH_NAME}" + ) + typer.echo( + f"Branch {FEATURE_BRANCH_NAME} created locally from origin." + ) except git.GitCommandError: - typer.echo(f"Branch {FEATURE_BRANCH_NAME} does not exist on origin. Creating an empty branch.") + typer.echo( + f"Branch {FEATURE_BRANCH_NAME} does not exist on origin. Creating an empty branch." + ) create_empty_branch(FEATURE_BRANCH_NAME, repo) try: typer.echo("Fetching new feature-metadata") @@ -163,7 +176,7 @@ def get_current_branch() -> str: def sync_feature_branch(): with repo_context() as repo: - remote_name = "origin" + remote_name = "origin" try: print(f"Fetching {FEATURE_BRANCH_NAME} from {remote_name}") repo.git.fetch(remote_name, FEATURE_BRANCH_NAME) @@ -173,7 +186,9 @@ def sync_feature_branch(): try: print(f"Pushing {FEATURE_BRANCH_NAME} to {remote_name}") - repo.git.push(remote_name, FEATURE_BRANCH_NAME, force_with_lease=True) + repo.git.push( + remote_name, FEATURE_BRANCH_NAME, force_with_lease=True + ) except Exception as e: # print(f"Error pushing the branch: {e}") print("Warning: Feature Updates could not be pushed to remote") diff --git a/git_tool/feature_data/read_feature_data/parse_data.py b/git_tool/feature_data/read_feature_data/parse_data.py index 29c4433..304a6ff 100644 --- a/git_tool/feature_data/read_feature_data/parse_data.py +++ b/git_tool/feature_data/read_feature_data/parse_data.py @@ -1,5 +1,5 @@ """ - All functions needed to parse data from feature files. +All functions needed to parse data from feature files. """ @@ -15,6 +15,7 @@ repo_context, ) + # Usages: FEATURE INFO-ALL def _get_feature_uuids() -> list[str]: """ @@ -91,6 +92,7 @@ def get_feature_log(feature_uuid: str): ) ) + # Usages: compare_branches.py (potentially FEATURE BLAME) def get_features_touched_by_commit(commit: Commit) -> Set[str]: """ diff --git a/git_tool/feature_data/utils/fast_import_utils.py b/git_tool/feature_data/utils/fast_import_utils.py index 75dd546..bd62ce5 100644 --- a/git_tool/feature_data/utils/fast_import_utils.py +++ b/git_tool/feature_data/utils/fast_import_utils.py @@ -94,9 +94,7 @@ def to_partial_fast_import_format(self) -> str: result.append(self.message) with repo_context.repo_context() as repo: try: - from_message: str = ( - f"from {repo.git.rev_parse(f'refs/heads/{self.branch_name}')}" - ) + from_message: str = f"from {repo.git.rev_parse(f'refs/heads/{self.branch_name}')}" result.append(from_message) except GitCommandError: print( diff --git a/git_tool/git_feature.py b/git_tool/git_feature.py new file mode 100644 index 0000000..15ad524 --- /dev/null +++ b/git_tool/git_feature.py @@ -0,0 +1,58 @@ +import typer + +from git_tool.ci.subcommands.feature_add import feature_add_by_add +from git_tool.ci.subcommands.feature_add_from_staged import ( + features_from_staging_area, +) +from git_tool.ci.subcommands.feature_blame import feature_blame +from git_tool.ci.subcommands.feature_commit import feature_commit +from git_tool.ci.subcommands.feature_commit_msg import feature_commit_msg +from git_tool.ci.subcommands.feature_commits import app as feature_commits +from git_tool.ci.subcommands.feature_info import inspect_feature +from git_tool.ci.subcommands.feature_info_all import all_feature_info +from git_tool.ci.subcommands.feature_pre_commit import feature_pre_commit +from git_tool.ci.subcommands.feature_status import feature_status + +app = typer.Typer( + name="feature", no_args_is_help=True +) # "git feature --help" does not work, but "git-feature --help" does +app.command( + name="add", + help="Stage files and associate them with the provided features.", +)(feature_add_by_add) +app.command( + name="add-from-staged", help="Associate staged files with features." +)(features_from_staging_area) +app.command(name="blame", help="Display features associated with file lines.")( + feature_blame +) +app.command( + name="commit", + help="Associate an existing commit with one or more features.", +)(feature_commit) +app.command( + name="commit-msg", + help="Generate feature information for the commit message.", +)(feature_commit_msg) +app.add_typer( + feature_commits, + name="commits", + help="Use with the subcommand 'list' or 'missing' to show commits with or without associated features.", +) +app.command(name="info", help="Show information of a specific feature.")( + inspect_feature +) +app.command( + name="info-all", help="List all available features in the project." +)(all_feature_info) +app.command( + name="pre-commit", + help="Check if all staged changes are properly associated with features.", +)(feature_pre_commit) +app.command( + name="status", + help="Display unstaged and staged changes with associated features.", +)(feature_status) + + +app() diff --git a/git_tool/git_variant.py b/git_tool/git_variant.py new file mode 100644 index 0000000..cf524e1 --- /dev/null +++ b/git_tool/git_variant.py @@ -0,0 +1,15 @@ +import typer + +from git_tool.ci.subcommands.variant_derive import derive_variant +from git_tool.ci.subcommands.variant_list import list_variant + +app = typer.Typer(name="variant", no_args_is_help=True) +app.command( + "derive", + no_args_is_help=True, +)(derive_variant) +app.command( + "list", +)(list_variant) + +app() diff --git a/git_tool/scripts_for_experiment/set_hooks_path.py b/git_tool/scripts_for_experiment/set_hooks_path.py index dbd3ea5..5204bfc 100644 --- a/git_tool/scripts_for_experiment/set_hooks_path.py +++ b/git_tool/scripts_for_experiment/set_hooks_path.py @@ -3,11 +3,12 @@ import sys import git_tool -''' +""" This script sets the hooks path for git. It has been added to the pyproject.toml file as "feature-init-hooks" Originally, the user had to set the hooks path manually, but not anymore. -''' +""" + def main(): try: @@ -21,7 +22,9 @@ def main(): print(f"Using hooks directory: {hook_path}") # Step 3: Set git hooksPath - subprocess.run(["git", "config", "core.hooksPath", hook_path], check=True) + subprocess.run( + ["git", "config", "core.hooksPath", hook_path], check=True + ) print("Git hooks path set successfully.") # Step 4: Check the current hooks path @@ -29,7 +32,7 @@ def main(): ["git", "rev-parse", "--git-path", "hooks"], check=True, stdout=subprocess.PIPE, - text=True + text=True, ) current_hook_path = result.stdout.strip() print(f"Git is now using hooks from: {current_hook_path}") @@ -39,4 +42,4 @@ def main(): sys.exit(1) except Exception as e: print("Something went wrong:", e) - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index 42e177b..db4b1ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,8 @@ dependencies = [ ] [project.scripts] -git-feature = "git_tool.__main__.py:app" +git-feature = "git_tool.git_feature:app" +git-variant = "git_tool.git_variant:app" feature-init-hooks = "git_tool.scripts_for_experiment.set_hooks_path:main" [build-system] @@ -50,3 +51,12 @@ extend-exclude = ''' | scripts/generate_schema.py # Uses match syntax ) ''' + +[tool.ruff] +line-length = 80 +target-version = "py38" +exclude = [ + "tests/data", + "profiling", + "scripts/generate_schema.py", +] diff --git a/test/test_finding_features.py b/test/test_finding_features.py index 12607b9..97eadeb 100644 --- a/test/test_finding_features.py +++ b/test/test_finding_features.py @@ -41,9 +41,9 @@ def test_git_features(git_repo): (finding_features.get_features_for_diff(d), d.a_path) for d in diff ] found_features = mapped_to_feature[0][0] - assert ( - len(found_features) == 1 - ), "Expecting one Feature that is not committed" - assert ( - found_features[0].name == "Feature1" - ), "Expecting to find the correct name for feature" + assert len(found_features) == 1, ( + "Expecting one Feature that is not committed" + ) + assert found_features[0].name == "Feature1", ( + "Expecting to find the correct name for feature" + )