panqake 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
panqake/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Panqake - Git branch stacking utility."""
2
+
3
+ __version__ = "0.1.0"
panqake/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running the package directly with python -m panqake."""
2
+
3
+ from panqake.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
panqake/cli.py ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Panqake - Git Branch Stacking Utility
4
+ A Python implementation of git-stacking workflow management
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+
10
+ from panqake.commands.delete import delete_branch
11
+ from panqake.commands.list import list_branches
12
+ from panqake.commands.new import create_new_branch
13
+ from panqake.commands.pr import create_pull_requests
14
+ from panqake.commands.switch import switch_branch
15
+ from panqake.commands.update import update_branches
16
+ from panqake.utils.config import init_panqake
17
+ from panqake.utils.git import is_git_repo
18
+
19
+
20
+ def main():
21
+ """Main entry point for the panqake CLI."""
22
+ parser = argparse.ArgumentParser(
23
+ description="Panqake - Git Branch Stacking Utility"
24
+ )
25
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
26
+
27
+ # new command
28
+ new_parser = subparsers.add_parser("new", help="Create a new branch in the stack")
29
+ new_parser.add_argument(
30
+ "branch_name",
31
+ nargs="?",
32
+ help="Name of the new branch to create",
33
+ )
34
+ new_parser.add_argument(
35
+ "base_branch",
36
+ nargs="?",
37
+ help="Optional base branch (defaults to current branch)",
38
+ )
39
+
40
+ # list command
41
+ list_parser = subparsers.add_parser("list", help="List the branch stack")
42
+ list_parser.add_argument(
43
+ "branch_name",
44
+ nargs="?",
45
+ help="Optional branch to start from (defaults to current branch)",
46
+ )
47
+
48
+ # update command
49
+ update_parser = subparsers.add_parser(
50
+ "update", help="Update branches after changes"
51
+ )
52
+ update_parser.add_argument(
53
+ "branch_name",
54
+ nargs="?",
55
+ help="Optional branch to start from (defaults to current branch)",
56
+ )
57
+
58
+ # delete command
59
+ delete_parser = subparsers.add_parser(
60
+ "delete", help="Delete a branch and relink the stack"
61
+ )
62
+ delete_parser.add_argument("branch_name", help="Name of the branch to delete")
63
+
64
+ # pr command
65
+ pr_parser = subparsers.add_parser("pr", help="Create PRs for the branch stack")
66
+ pr_parser.add_argument(
67
+ "branch_name",
68
+ nargs="?",
69
+ help="Optional branch to start from (defaults to current branch)",
70
+ )
71
+
72
+ # switch command
73
+ switch_parser = subparsers.add_parser(
74
+ "switch", help="Interactively switch between branches"
75
+ )
76
+ switch_parser.add_argument(
77
+ "branch_name",
78
+ nargs="?",
79
+ help="Optional branch to switch to (defaults to interactive selection)",
80
+ )
81
+
82
+ args = parser.parse_args()
83
+
84
+ if not args.command:
85
+ parser.print_help()
86
+ return
87
+
88
+ # Initialize panqake directory and files
89
+ init_panqake()
90
+
91
+ # Check if we're in a git repository
92
+ if not is_git_repo():
93
+ print("Error: Not in a git repository")
94
+ sys.exit(1)
95
+
96
+ # Execute the appropriate command
97
+ if args.command == "new":
98
+ create_new_branch(args.branch_name, args.base_branch)
99
+ elif args.command == "list":
100
+ list_branches(args.branch_name)
101
+ elif args.command == "update":
102
+ update_branches(args.branch_name)
103
+ elif args.command == "delete":
104
+ delete_branch(args.branch_name)
105
+ elif args.command == "pr":
106
+ create_pull_requests(args.branch_name)
107
+ elif args.command == "switch":
108
+ switch_branch(args.branch_name)
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
@@ -0,0 +1 @@
1
+ """Command modules for panqake git-stacking utility."""
@@ -0,0 +1,153 @@
1
+ """Command for deleting a branch and relinking the stack."""
2
+
3
+ import sys
4
+
5
+ from panqake.utils.config import (
6
+ add_to_stack,
7
+ get_child_branches,
8
+ get_parent_branch,
9
+ remove_from_stack,
10
+ )
11
+ from panqake.utils.git import branch_exists, get_current_branch, run_git_command
12
+ from panqake.utils.questionary_prompt import (
13
+ format_branch,
14
+ print_formatted_text,
15
+ prompt_confirm,
16
+ )
17
+
18
+
19
+ def validate_branch_for_deletion(branch_name):
20
+ """Validate that a branch can be deleted."""
21
+ current_branch = get_current_branch()
22
+
23
+ # Check if target branch exists
24
+ if not branch_exists(branch_name):
25
+ print_formatted_text(
26
+ f"<warning>Error: Branch '{branch_name}' does not exist</warning>"
27
+ )
28
+ sys.exit(1)
29
+
30
+ # Check if target branch is the current branch
31
+ if branch_name == current_branch:
32
+ print_formatted_text(
33
+ "<warning>Error: Cannot delete the current branch. Please checkout another branch first.</warning>"
34
+ )
35
+ sys.exit(1)
36
+
37
+ return current_branch
38
+
39
+
40
+ def get_branch_relationships(branch_name):
41
+ """Get parent and child branches and validate parent exists."""
42
+ parent_branch = get_parent_branch(branch_name)
43
+ child_branches = get_child_branches(branch_name)
44
+
45
+ # Ensure parent branch exists
46
+ if parent_branch and not branch_exists(parent_branch):
47
+ print_formatted_text(
48
+ f"<warning>Error: Parent branch '{parent_branch}' does not exist</warning>"
49
+ )
50
+ sys.exit(1)
51
+
52
+ return parent_branch, child_branches
53
+
54
+
55
+ def display_deletion_info(branch_name, parent_branch, child_branches):
56
+ """Display deletion information and ask for confirmation."""
57
+ print_formatted_text(
58
+ f"<info>Branch to delete:</info> {format_branch(branch_name, danger=True)}"
59
+ )
60
+ if parent_branch:
61
+ print_formatted_text(
62
+ f"<info>Parent branch:</info> {format_branch(parent_branch)}"
63
+ )
64
+ if child_branches:
65
+ print_formatted_text("<info>Child branches that will be relinked:</info>")
66
+ for child in child_branches:
67
+ print_formatted_text(f" {format_branch(child)}")
68
+
69
+ # Confirm deletion
70
+ if not prompt_confirm("Are you sure you want to delete this branch?"):
71
+ print_formatted_text("<info>Branch deletion cancelled.</info>")
72
+ return False
73
+
74
+ return True
75
+
76
+
77
+ def relink_child_branches(child_branches, parent_branch, current_branch, branch_name):
78
+ """Relink child branches to the parent branch."""
79
+ if not child_branches:
80
+ return True
81
+
82
+ print_formatted_text(
83
+ f"<info>Relinking child branches to parent '{parent_branch}'...</info>"
84
+ )
85
+
86
+ for child in child_branches:
87
+ print_formatted_text(
88
+ f"<info>Processing child branch:</info> {format_branch(child)}"
89
+ )
90
+
91
+ # Checkout the child branch
92
+ checkout_result = run_git_command(["checkout", child])
93
+ if checkout_result is None:
94
+ print_formatted_text(
95
+ f"<warning>Error: Failed to checkout branch '{child}'</warning>"
96
+ )
97
+ run_git_command(["checkout", current_branch])
98
+ sys.exit(1)
99
+
100
+ # Rebase onto the grandparent branch
101
+ if parent_branch:
102
+ rebase_result = run_git_command(["rebase", parent_branch])
103
+ if rebase_result is None:
104
+ print_formatted_text(
105
+ f"<warning>Error: Rebase conflict detected in branch '{child}'</warning>"
106
+ )
107
+ print_formatted_text(
108
+ "<warning>Please resolve conflicts and run 'git rebase --continue'</warning>"
109
+ )
110
+ print_formatted_text(
111
+ f"<warning>Then run 'panqake delete {branch_name}' again to retry</warning>"
112
+ )
113
+ sys.exit(1)
114
+
115
+ # Update stack metadata
116
+ add_to_stack(child, parent_branch)
117
+
118
+ return True
119
+
120
+
121
+ def delete_branch(branch_name):
122
+ """Delete a branch and relink the stack."""
123
+ current_branch = validate_branch_for_deletion(branch_name)
124
+ parent_branch, child_branches = get_branch_relationships(branch_name)
125
+
126
+ if not display_deletion_info(branch_name, parent_branch, child_branches):
127
+ return
128
+
129
+ print_formatted_text(
130
+ f"<info>Deleting branch '{branch_name}' from the stack...</info>"
131
+ )
132
+
133
+ # Process child branches
134
+ relink_child_branches(child_branches, parent_branch, current_branch, branch_name)
135
+
136
+ # Return to original branch if it's not the one being deleted
137
+ if branch_name != current_branch:
138
+ run_git_command(["checkout", current_branch])
139
+
140
+ # Delete the branch
141
+ delete_result = run_git_command(["branch", "-D", branch_name])
142
+ if delete_result is None:
143
+ print_formatted_text(
144
+ f"<warning>Error: Failed to delete branch '{branch_name}'</warning>"
145
+ )
146
+ sys.exit(1)
147
+
148
+ # Remove from stack metadata
149
+ remove_from_stack(branch_name)
150
+
151
+ print_formatted_text(
152
+ f"<success>Success! Deleted branch '{branch_name}' and relinked the stack</success>"
153
+ )
@@ -0,0 +1,74 @@
1
+ """Command for listing branches in the stack."""
2
+
3
+ import sys
4
+
5
+ from panqake.utils.config import get_child_branches, get_parent_branch
6
+ from panqake.utils.git import branch_exists, get_current_branch
7
+
8
+
9
+ def find_stack_root(branch):
10
+ """Find the root of the stack for a given branch."""
11
+ parent = get_parent_branch(branch)
12
+
13
+ if not parent:
14
+ return branch
15
+ else:
16
+ return find_stack_root(parent)
17
+
18
+
19
+ def print_branch_tree(branch, indent="", is_last_sibling=True):
20
+ """Recursively print the branch tree."""
21
+ current_branch = get_current_branch()
22
+ is_current = branch == current_branch
23
+
24
+ # Determine the connector for the current branch
25
+ if indent: # Not the root
26
+ connector = "└── " if is_last_sibling else "├── "
27
+ else: # Root branch
28
+ connector = ""
29
+
30
+ # Format and print the current branch line
31
+ prefix = f"{indent}{connector}"
32
+ if is_current:
33
+ # Apply branch styling using ANSI codes
34
+ branch_display = f"* {branch}"
35
+ # Print prefix first, then styled branch with ANSI codes
36
+ print(f"{prefix}\033[92m{branch_display}\033[0m")
37
+ else:
38
+ # Non-current branches use default terminal text color
39
+ print(f"{prefix}{branch}")
40
+
41
+ # Prepare the indentation for children
42
+ # Add a vertical bar if this branch is not the last sibling, otherwise add spaces
43
+ child_indent = indent + (" " if is_last_sibling else "│ ")
44
+
45
+ # Get children of this branch
46
+ children = get_child_branches(branch)
47
+ num_children = len(children)
48
+
49
+ if children:
50
+ for i, child in enumerate(children):
51
+ is_last_child = i == num_children - 1
52
+ print_branch_tree(child, child_indent, is_last_child)
53
+
54
+
55
+ def list_branches(branch_name=None):
56
+ """List the branch stack."""
57
+ # If no branch specified, use current branch
58
+ if not branch_name:
59
+ branch_name = get_current_branch()
60
+
61
+ # Check if target branch exists
62
+ if not branch_exists(branch_name):
63
+ # Use standard print with ANSI codes for warning style (yellow)
64
+ print(f"\033[93mError: Branch '{branch_name}' does not exist\033[0m")
65
+ sys.exit(1)
66
+
67
+ # Find the root of the stack for the target branch
68
+ root_branch = find_stack_root(branch_name)
69
+
70
+ # Use standard print with ANSI codes for info style (cyan)
71
+ print(f"\033[96mBranch stack (current: {get_current_branch()})\033[0m")
72
+
73
+ # Initial call starts with no indent and assumes the root is the 'last sibling' conceptually
74
+ print_branch_tree(root_branch, indent="", is_last_sibling=True)
@@ -0,0 +1,56 @@
1
+ """Command for creating a new branch in the stack."""
2
+
3
+ import sys
4
+
5
+ from panqake.utils.config import add_to_stack
6
+ from panqake.utils.git import (
7
+ branch_exists,
8
+ get_current_branch,
9
+ list_all_branches,
10
+ run_git_command,
11
+ )
12
+ from panqake.utils.questionary_prompt import BranchNameValidator, prompt_input
13
+
14
+
15
+ def create_new_branch(branch_name=None, base_branch=None):
16
+ """Create a new branch in the stack."""
17
+ # If no branch name specified, prompt for it
18
+ if not branch_name:
19
+ validator = BranchNameValidator()
20
+ branch_name = prompt_input("Enter new branch name: ", validator=validator)
21
+
22
+ # If no base branch specified, use current branch but offer selection
23
+ current = get_current_branch()
24
+ if not base_branch:
25
+ base_branch = current
26
+ branches = list_all_branches()
27
+ if branches:
28
+ base_branch = prompt_input(
29
+ f"Enter base branch [default: {current}]: ",
30
+ completer=branches,
31
+ default=current,
32
+ )
33
+
34
+ # Check if the new branch already exists
35
+ if branch_exists(branch_name):
36
+ print(f"Error: Branch '{branch_name}' already exists")
37
+ sys.exit(1)
38
+
39
+ # Check if the base branch exists
40
+ if base_branch and not branch_exists(base_branch):
41
+ print(f"Error: Base branch '{base_branch}' does not exist")
42
+ sys.exit(1)
43
+
44
+ print(f"Creating new branch '{branch_name}' based on '{base_branch}'...")
45
+
46
+ # Create the new branch
47
+ result = run_git_command(["checkout", "-b", branch_name, base_branch])
48
+ if result is None:
49
+ print("Error: Failed to create new branch")
50
+ sys.exit(1)
51
+
52
+ # Record the dependency information
53
+ add_to_stack(branch_name, base_branch)
54
+
55
+ print(f"Success! Created new branch '{branch_name}' in the stack")
56
+ print(f"Parent branch: {base_branch}")
panqake/commands/pr.py ADDED
@@ -0,0 +1,173 @@
1
+ """Command for creating pull requests for branches in the stack."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+
7
+ from panqake.utils.config import get_child_branches, get_parent_branch
8
+ from panqake.utils.git import branch_exists, get_current_branch, run_git_command
9
+ from panqake.utils.questionary_prompt import (
10
+ PRTitleValidator,
11
+ format_branch,
12
+ print_formatted_text,
13
+ prompt_confirm,
14
+ prompt_input,
15
+ )
16
+
17
+
18
+ def find_oldest_branch_without_pr(branch):
19
+ """Find the bottom-most branch without a PR."""
20
+ parent = get_parent_branch(branch)
21
+
22
+ # If no parent or parent is main/master, we've reached the bottom
23
+ if not parent or parent in ["main", "master"]:
24
+ return branch
25
+
26
+ # Check if parent branch already has a PR
27
+ if branch_has_pr(parent):
28
+ # Parent already has a PR, so this is the bottom-most branch without one
29
+ return branch
30
+ else:
31
+ # Parent doesn't have a PR, check further down the stack
32
+ return find_oldest_branch_without_pr(parent)
33
+
34
+
35
+ def branch_has_pr(branch):
36
+ """Check if a branch already has a PR."""
37
+ try:
38
+ subprocess.run(
39
+ ["gh", "pr", "view", branch],
40
+ check=True,
41
+ stdout=subprocess.PIPE,
42
+ stderr=subprocess.PIPE,
43
+ )
44
+ return True
45
+ except subprocess.CalledProcessError:
46
+ return False
47
+
48
+
49
+ def create_pr_for_branch(branch, parent):
50
+ """Create a PR for a specific branch."""
51
+ # Get commit message for default PR title
52
+ commit_message = run_git_command(["log", "-1", "--pretty=%s", branch])
53
+ default_title = (
54
+ f"[{branch}] {commit_message}" if commit_message else f"[{branch}] Stacked PR"
55
+ )
56
+
57
+ # Prompt for PR details
58
+ title = prompt_input(
59
+ "Enter PR title: ", validator=PRTitleValidator(), default=default_title
60
+ )
61
+
62
+ description = prompt_input(
63
+ "Enter PR description (optional): ",
64
+ default="This is part of a stacked PR series.",
65
+ )
66
+
67
+ # Show summary and confirm
68
+ print_formatted_text(f"<info>PR for branch:</info> {format_branch(branch)}")
69
+ print_formatted_text(f"<info>Target branch:</info> {format_branch(parent)}")
70
+ print_formatted_text(f"<info>Title:</info> {title}")
71
+
72
+ if not prompt_confirm("Create this pull request?"):
73
+ print_formatted_text("<info>PR creation skipped.</info>")
74
+ return False
75
+
76
+ # Create the PR
77
+ try:
78
+ subprocess.run(
79
+ [
80
+ "gh",
81
+ "pr",
82
+ "create",
83
+ "--base",
84
+ parent,
85
+ "--head",
86
+ branch,
87
+ "--title",
88
+ title,
89
+ "--body",
90
+ description,
91
+ ],
92
+ check=True,
93
+ )
94
+ print_formatted_text(
95
+ f"<success>PR created successfully for {format_branch(branch)}</success>"
96
+ )
97
+ return True
98
+ except subprocess.CalledProcessError:
99
+ print_formatted_text(
100
+ f"<warning>Error: Failed to create PR for branch '{branch}'</warning>"
101
+ )
102
+ sys.exit(1)
103
+
104
+
105
+ def is_branch_in_path_to_target(child, branch_name, parent_branch):
106
+ """Check if a child branch is in the path to the target branch."""
107
+ current = branch_name
108
+ while current and current != parent_branch:
109
+ if current == child:
110
+ return True
111
+ current = get_parent_branch(current)
112
+
113
+ return False
114
+
115
+
116
+ def process_branch_for_pr(branch, target_branch):
117
+ """Process a branch to create PR and handle its children."""
118
+ if branch_has_pr(branch):
119
+ print_formatted_text(f"Branch {format_branch(branch)} already has an open PR")
120
+ else:
121
+ print_formatted_text(
122
+ f"<info>Creating PR for branch:</info> {format_branch(branch)}"
123
+ )
124
+
125
+ # Get parent branch for PR target
126
+ parent = get_parent_branch(branch)
127
+ if not parent:
128
+ parent = "main" # Default to main if no parent
129
+
130
+ create_pr_for_branch(branch, parent)
131
+
132
+ # Process any children of this branch that lead to the target
133
+ for child in get_child_branches(branch):
134
+ if (
135
+ is_branch_in_path_to_target(child, target_branch, branch)
136
+ or child == target_branch
137
+ ):
138
+ process_branch_for_pr(child, target_branch)
139
+
140
+
141
+ def create_pull_requests(branch_name=None):
142
+ """Create pull requests for branches in the stack."""
143
+ # Check for GitHub CLI
144
+ if not shutil.which("gh"):
145
+ print_formatted_text(
146
+ "<warning>Error: GitHub CLI (gh) is required but not installed.</warning>"
147
+ )
148
+ print_formatted_text(
149
+ "<info>Please install GitHub CLI: https://cli.github.com/manual/installation</info>"
150
+ )
151
+ sys.exit(1)
152
+
153
+ # If no branch specified, use current branch
154
+ if not branch_name:
155
+ branch_name = get_current_branch()
156
+
157
+ # Check if target branch exists
158
+ if not branch_exists(branch_name):
159
+ print_formatted_text(
160
+ f"<warning>Error: Branch '{branch_name}' does not exist</warning>"
161
+ )
162
+ sys.exit(1)
163
+
164
+ # Find the oldest branch in the stack that needs a PR
165
+ oldest_branch = find_oldest_branch_without_pr(branch_name)
166
+
167
+ print_formatted_text(
168
+ f"<info>Creating PRs from the bottom of the stack up to:</info> {format_branch(branch_name)}"
169
+ )
170
+
171
+ process_branch_for_pr(oldest_branch, branch_name)
172
+
173
+ print_formatted_text("<success>Pull request creation complete</success>")
@@ -0,0 +1,83 @@
1
+ """Command for switching between Git branches."""
2
+
3
+ import sys
4
+
5
+ import questionary
6
+
7
+ from panqake.utils.git import get_current_branch, list_all_branches, run_git_command
8
+ from panqake.utils.questionary_prompt import print_formatted_text
9
+
10
+
11
+ def switch_branch(branch_name=None):
12
+ """Switch to another git branch using interactive selection.
13
+
14
+ Args:
15
+ branch_name: Optional branch name to switch to directly.
16
+ If not provided, shows an interactive selection.
17
+ """
18
+ # Get all available branches
19
+ branches = list_all_branches()
20
+
21
+ if not branches:
22
+ print_formatted_text("<warning>No branches found in repository</warning>")
23
+ sys.exit(1)
24
+
25
+ current = get_current_branch()
26
+
27
+ # If branch name is provided, switch directly
28
+ if branch_name:
29
+ if branch_name not in branches:
30
+ print_formatted_text(
31
+ f"<warning>Error: Branch '{branch_name}' does not exist</warning>"
32
+ )
33
+ sys.exit(1)
34
+
35
+ if branch_name == current:
36
+ print_formatted_text(f"<info>Already on branch '{branch_name}'</info>")
37
+ return
38
+
39
+ _checkout_branch(branch_name)
40
+ return
41
+
42
+ # Format branches for display, marking the current branch
43
+ choices = []
44
+ for branch in branches:
45
+ is_current = branch == current
46
+ if is_current:
47
+ # Add a special marker for the current branch
48
+ choices.append(
49
+ questionary.Choice(
50
+ title=f"* {branch} (current)",
51
+ value=branch,
52
+ disabled="current branch",
53
+ )
54
+ )
55
+ else:
56
+ choices.append(branch)
57
+
58
+ # Show interactive branch selection
59
+ selected = questionary.select(
60
+ "Select a branch to switch to:",
61
+ choices=choices,
62
+ ).ask()
63
+
64
+ if selected:
65
+ if selected == current:
66
+ print_formatted_text(f"<info>Already on branch '{selected}'</info>")
67
+ return
68
+
69
+ _checkout_branch(selected)
70
+
71
+
72
+ def _checkout_branch(branch_name):
73
+ """Checkout to the specified branch."""
74
+ print_formatted_text(f"<info>Switching to branch '{branch_name}'...</info>")
75
+ result = run_git_command(["checkout", branch_name])
76
+
77
+ if result is not None:
78
+ print_formatted_text(
79
+ f"<success>Successfully switched to branch '{branch_name}'</success>"
80
+ )
81
+ else:
82
+ print_formatted_text("<danger>Failed to switch branches</danger>")
83
+ sys.exit(1)