# git_commands.py import os import subprocess import logging import re class GitCommandError(Exception): """ Custom exception for Git command errors. """ def __init__(self, message, command=None, stderr=None): super().__init__(message) self.command = command self.stderr = stderr def __str__(self): base = super().__str__(); details = [] if self.command: details.append(f"Cmd: '{' '.join(map(str, self.command))}'") if self.stderr: details.append(f"Stderr: {self.stderr.strip()}") return f"{base} ({'; '.join(details)})" if details else base class GitCommands: """ Manages Git commands execution. """ def __init__(self, logger): """ Initializes with a logger. """ if not isinstance(logger, logging.Logger): raise ValueError("Valid logger required.") self.logger = logger def log_and_execute(self, command, working_directory, check=True): """ Executes a command, logs, handles errors. """ safe_cmd = [str(p) for p in command]; cmd_str = ' '.join(safe_cmd) self.logger.debug(f"Executing: {cmd_str}") if not working_directory: raise ValueError("Working directory required.") abs_path = os.path.abspath(working_directory) if not os.path.isdir(abs_path): raise GitCommandError(f"Invalid WD: {abs_path}", safe_cmd) cwd = abs_path; self.logger.debug(f"WD: {cwd}") try: startupinfo = None; creationflags = 0 if os.name == 'nt': startupinfo = subprocess.STARTUPINFO(); startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE result = subprocess.run( safe_cmd, cwd=cwd, capture_output=True, text=True, check=check, encoding='utf-8', errors='replace', startupinfo=startupinfo, creationflags=creationflags ) out = result.stdout.strip() or ""; err = result.stderr.strip() or "" self.logger.info(f"Success. Output:\n--- stdout ---\n{out}\n--- stderr ---\n{err}\n---") return result except subprocess.CalledProcessError as e: err = e.stderr.strip() or ""; out = e.stdout.strip() or "" log_msg = f"Cmd failed (code {e.returncode}) in '{cwd}'.\nCmd: {cmd_str}\nStderr: {err}\nStdout: {out}" self.logger.error(log_msg) raise GitCommandError(f"Git cmd failed in '{cwd}'.", safe_cmd, e.stderr) from e except FileNotFoundError as e: self.logger.error(f"Cmd not found: '{safe_cmd[0]}'"); raise GitCommandError(f"Cmd not found: {safe_cmd[0]}", safe_cmd) from e except PermissionError as e: self.logger.error(f"Permission denied in '{cwd}'."); raise GitCommandError(f"Permission denied", safe_cmd, str(e)) from e except Exception as e: self.logger.exception(f"Unexpected error in '{cwd}': {e}"); raise GitCommandError(f"Unexpected error: {e}", safe_cmd) from e # --- Existing Methods (Prepare, Bundle, Commit, Status, Tag) --- # (Code omitted for brevity - Ensure they are present and correct) def prepare_svn_for_git(self, working_directory): """ Prepares a directory for use with Git. """ self.logger.info(f"Preparing directory for Git: '{working_directory}'") if not working_directory: raise ValueError("WD empty.") if not os.path.isdir(working_directory): raise GitCommandError(f"Dir not exists: {working_directory}") gitignore = os.path.join(working_directory, ".gitignore") git_dir = os.path.join(working_directory, ".git") if not os.path.exists(git_dir): self.logger.info("Initializing Git repo...") try: self.log_and_execute(["git", "init"], working_directory, check=True); self.logger.info("Repo initialized.") except Exception as e: self.logger.error(f"Failed init: {e}"); raise else: self.logger.info("Repo already exists.") self.logger.debug(f"Checking/updating gitignore: {gitignore}") try: entry = ".svn"; needs_write = False; content = "" if not os.path.exists(gitignore): needs_write = True; content = f"{entry}\n"; self.logger.info("Creating .gitignore.") else: try: with open(gitignore, "r", encoding='utf-8') as f: lines = f.readlines() ignored = any(l.strip() == entry or l.strip().startswith(entry + '/') for l in lines) if not ignored: needs_write = True; current = "".join(lines); content = f"\n{entry}\n" if not current.endswith('\n') else f"{entry}\n"; self.logger.info("Appending .svn to .gitignore.") else: self.logger.info(".svn already ignored.") except IOError as e: self.logger.warning(f"Cannot read gitignore: {e}") if needs_write: mode = 'a' if os.path.exists(gitignore) else 'w' try: with open(gitignore, mode, encoding='utf-8', newline='\n') as f: f.write(content) self.logger.info("Updated .gitignore.") except IOError as e: self.logger.error(f"Write error gitignore: {e}"); raise GitCommandError(f"Failed update gitignore: {e}") from e except Exception as e: self.logger.exception(f"Gitignore error: {e}"); raise GitCommandError(f"Gitignore error: {e}") from e self.logger.info("Preparation complete.") def create_git_bundle(self, wd, path): """ Creates a Git bundle file. """ norm_path = os.path.normpath(path).replace("\\", "/"); cmd = ["git", "bundle", "create", norm_path, "--all"]; self.logger.info(f"Creating bundle: {norm_path}") try: res = self.log_and_execute(cmd, wd, check=False) if res.returncode != 0: err = res.stderr.lower() if res.stderr else "" if "refusing to create empty bundle" in err: self.logger.warning(f"Empty bundle skipped for '{wd}'.") else: raise GitCommandError(f"Bundle cmd failed code {res.returncode}", cmd, res.stderr) elif not os.path.exists(norm_path) or os.path.getsize(norm_path) == 0: self.logger.warning(f"Bundle file missing/empty: {norm_path}") else: self.logger.info(f"Bundle created: '{norm_path}'.") except (GitCommandError, ValueError) as e: self.logger.error(f"Failed create bundle for '{wd}': {e}"); raise except Exception as e: self.logger.exception(f"Unexpected bundle error for '{wd}': {e}"); raise GitCommandError(f"Unexpected bundle error: {e}", cmd) from e def fetch_from_git_bundle(self, wd, path): """ Fetches from a bundle and merges. """ norm_path = os.path.normpath(path).replace("\\", "/"); self.logger.info(f"Fetching from '{norm_path}' into '{wd}'") fetch_cmd = ["git", "fetch", norm_path]; merge_cmd = ["git", "merge", "FETCH_HEAD", "--no-ff"] try: self.logger.debug("Executing fetch..."); self.log_and_execute(fetch_cmd, wd, check=True); self.logger.info("Fetch successful.") self.logger.debug("Executing merge..."); merge_res = self.log_and_execute(merge_cmd, wd, check=False) out = merge_res.stdout.strip() or ""; err = merge_res.stderr.strip() or "" if merge_res.returncode == 0: if "already up to date" in out.lower(): self.logger.info("Already up-to-date.") else: self.logger.info("Merge successful.") else: if "conflict" in (err + out).lower(): msg = f"Merge conflict fetching. Resolve in '{wd}' and commit."; self.logger.error(msg); raise GitCommandError(msg, merge_cmd, merge_res.stderr) else: msg = f"Merge failed code {merge_res.returncode}."; self.logger.error(msg); raise GitCommandError(msg, merge_cmd, merge_res.stderr) except (GitCommandError, ValueError) as e: self.logger.error(f"Fetch/merge error for '{wd}': {e}"); raise except Exception as e: self.logger.exception(f"Unexpected fetch/merge error for '{wd}': {e}"); raise GitCommandError(f"Unexpected fetch/merge error: {e}") from e def git_commit(self, wd, msg="Autocommit"): """ Stages all and commits. """ self.logger.info(f"Attempting commit in '{wd}': '{msg}'") try: add_cmd = ["git", "add", "."]; self.logger.debug("Staging..."); self.log_and_execute(add_cmd, wd, check=True); self.logger.debug("Staged.") commit_cmd = ["git", "commit", "-m", msg]; self.logger.debug("Committing..."); res = self.log_and_execute(commit_cmd, wd, check=False) out_low = res.stdout.lower() if res.stdout else ""; err_low = res.stderr.lower() if res.stderr else "" if res.returncode == 0: self.logger.info("Commit successful."); return True elif "nothing to commit" in out_low or "no changes added" in out_low or "nothing added" in out_low or (res.returncode == 1 and not err_low and not out_low): self.logger.info("Nothing to commit."); return False else: msg_err = f"Commit failed code {res.returncode}."; raise GitCommandError(msg_err, commit_cmd, res.stderr) except (GitCommandError, ValueError) as e: self.logger.error(f"Commit process error: {e}"); raise except Exception as e: self.logger.exception(f"Unexpected commit error: {e}"); raise GitCommandError(f"Unexpected commit error: {e}") from e def git_status_has_changes(self, wd): """ Checks if repo has uncommitted changes. """ self.logger.debug(f"Checking status in '{wd}'...") try: cmd = ["git", "status", "--porcelain"]; res = self.log_and_execute(cmd, wd, check=True) changed = bool(res.stdout.strip()); self.logger.debug(f"Has changes: {changed}"); return changed except (GitCommandError, ValueError) as e: self.logger.error(f"Status check error: {e}"); raise except Exception as e: self.logger.exception(f"Unexpected status error: {e}"); raise GitCommandError(f"Unexpected status error: {e}") from e def list_tags(self, wd): """ Lists tags with subjects, sorted newest first. """ self.logger.info(f"Listing tags with subjects in '{wd}'...") fmt = "%(refname:short)%09%(contents:subject)"; cmd = ["git", "tag", "--list", f"--format={fmt}", "--sort=-creatordate"] tags = [] try: res = self.log_and_execute(cmd, wd, check=True) for line in res.stdout.splitlines(): if line.strip(): parts = line.split('\t', 1); name = parts[0].strip() subject = parts[1].strip() if len(parts) > 1 else "(No subject)" tags.append((name, subject)) self.logger.info(f"Found {len(tags)} tags."); self.logger.debug(f"Tags: {tags}"); return tags except (GitCommandError, ValueError) as e: self.logger.error(f"Error listing tags: {e}"); return [] except Exception as e: self.logger.exception(f"Unexpected error listing tags: {e}"); return [] def create_tag(self, wd, name, message): """ Creates an annotated tag. """ self.logger.info(f"Creating tag '{name}' in '{wd}'") if not name or not message: raise ValueError("Tag name/message empty.") pattern = r"^(?![./]|.*([./]{2,}|[.]$|\.lock$))[^ \t\n\r\f\v~^:?*[\\]+(?" # Indicate error state except (GitCommandError, ValueError) as e: self.logger.error(f"Error getting current branch: {e}") return "" except Exception as e: self.logger.exception(f"Unexpected error getting current branch: {e}") return "" def list_branches(self, working_directory): """ Lists local Git branches. Args: working_directory (str): Path to the local Git repository. Returns: list: A list of local branch names (str). Empty on error. """ self.logger.info(f"Listing local branches in '{working_directory}'...") cmd = ["git", "branch", "--list", "--no-color"] branches = [] try: result = self.log_and_execute(cmd, working_directory, check=True) for line in result.stdout.splitlines(): # Remove leading '*' and whitespace from branch names branch_name = line.lstrip('* ').strip() if branch_name and "HEAD detached" not in branch_name: # Filter out detached HEAD message branches.append(branch_name) self.logger.info(f"Found {len(branches)} local branches.") self.logger.debug(f"Branches: {branches}") return branches except (GitCommandError, ValueError) as e: self.logger.error(f"Error listing branches: {e}") return [] except Exception as e: self.logger.exception(f"Unexpected error listing branches: {e}") return [] def create_branch(self, working_directory, branch_name, start_point=None): """ Creates a new local Git branch. Args: working_directory (str): Path to the local Git repository. branch_name (str): The name for the new branch. start_point (str, optional): Commit, tag, or branch to start from. Defaults to current HEAD. Raises: GitCommandError: If branch name invalid, exists, or command fails. ValueError: If arguments invalid. """ self.logger.info(f"Creating branch '{branch_name}' in '{working_directory}'...") if not branch_name: raise ValueError("Branch name cannot be empty.") # Add validation for branch name format pattern = r"^(?![./]|.*([./]{2,}|[.]$|[/]$|@\{))[^ \t\n\r\f\v~^:?*[\\]+(?