multi-workspace 3.0.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.
multi/__init__.py ADDED
@@ -0,0 +1 @@
1
+
multi/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from multi.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
multi/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '3.0.0'
32
+ __version_tuple__ = version_tuple = (3, 0, 0)
33
+
34
+ __commit_id__ = commit_id = None
multi/cli.py ADDED
@@ -0,0 +1,45 @@
1
+ import click
2
+
3
+ from multi._version import __version__
4
+ from multi.cli_helpers import common_command_wrapper
5
+ from multi.git_run import git_cmd
6
+ from multi.git_set_branch import set_branch_cmd
7
+ from multi.init import init_cmd
8
+ from multi.sync import sync_cmd
9
+
10
+
11
+ def print_version(ctx, param, value):
12
+ if not value or ctx.resilient_parsing:
13
+ return
14
+ click.echo(f"multi (multi-sync) {__version__}")
15
+ ctx.exit()
16
+
17
+
18
+ @click.group()
19
+ @click.option(
20
+ "--version",
21
+ is_flag=True,
22
+ callback=print_version,
23
+ expose_value=False,
24
+ is_eager=True,
25
+ help="Show the version and exit.",
26
+ )
27
+ def main():
28
+ """VS Code Multi - Manage multiple Git repositories in VS Code.
29
+
30
+ This CLI tool enables seamless work across multiple Git repositories within VS Code.
31
+ Key features:
32
+ - Synchronize Git operations across root and sub-repositories
33
+ - Merge .vscode configurations (launch.json, tasks.json, settings.json)
34
+ - Manage consistent branch states across all repositories
35
+ """
36
+ pass
37
+
38
+
39
+ main.add_command(common_command_wrapper(set_branch_cmd))
40
+ main.add_command(common_command_wrapper(sync_cmd))
41
+ main.add_command(common_command_wrapper(git_cmd))
42
+ main.add_command(common_command_wrapper(init_cmd))
43
+
44
+ if __name__ == "__main__":
45
+ main()
multi/cli_helpers.py ADDED
@@ -0,0 +1,79 @@
1
+ import functools
2
+ import logging
3
+ import sys
4
+ import traceback
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from multi.errors import GitError
10
+ from multi.git_helpers import check_all_on_same_branch
11
+ from multi.logging import configure_logging
12
+ from multi.paths import Paths
13
+
14
+
15
+ def common_command_wrapper(command_to_wrap: click.Command) -> click.Command:
16
+ """
17
+ Wraps an existing Click command to add common functionality:
18
+ - A --verbose option for detailed logging.
19
+ - Standardized error handling and logging.
20
+ This function modifies the command_to_wrap in-place.
21
+ """
22
+ original_callback = command_to_wrap.callback
23
+ if not original_callback:
24
+ # This should generally not happen if command_to_wrap is created via @click.command
25
+ raise ValueError(
26
+ f"Command '{command_to_wrap.name or 'Unnamed'}' has no callback to wrap."
27
+ )
28
+
29
+ @functools.wraps(original_callback)
30
+ def new_wrapped_callback(**kwargs):
31
+ # Pop the verbose flag. It's added by this wrapper to the command's params.
32
+ # Click will pass it in kwargs to this new_callback.
33
+ verbose_value = kwargs.pop("verbose", False)
34
+
35
+ # Configure logging based on verbosity
36
+ log_level = logging.DEBUG if verbose_value else logging.INFO
37
+ configure_logging(level=log_level)
38
+
39
+ exit_code = None
40
+ try:
41
+ # Call the original command's callback with its intended kwargs
42
+ return original_callback(**kwargs)
43
+ except Exception as e:
44
+ logger = logging.getLogger(__name__) # Get logger after configuration
45
+ logger.error(str(e)) # This will use the emoji formatter
46
+ if verbose_value:
47
+ # For verbose mode, also print traceback directly to stderr
48
+ click.secho("\nDebug traceback:", fg="yellow", err=True)
49
+ click.secho(traceback.format_exc(), fg="yellow", err=True)
50
+ exit_code = 1
51
+
52
+ # After every command, check that all sub-repos are on the same branch as the root repo
53
+ try:
54
+ paths = Paths(Path.cwd())
55
+ check_all_on_same_branch(paths=paths, raise_error=True)
56
+ except GitError as e:
57
+ click.secho(e.args[0], fg="red", err=True)
58
+
59
+ if exit_code is not None:
60
+ sys.exit(exit_code)
61
+
62
+ # Replace the command's callback with our new wrapped version
63
+ command_to_wrap.callback = new_wrapped_callback
64
+
65
+ # Add the --verbose option to the command's parameters, if not already present
66
+ # This ensures the `verbose` kwarg is available in new_wrapped_callback
67
+ if not any(
68
+ isinstance(p, click.Option) and p.name == "verbose"
69
+ for p in command_to_wrap.params
70
+ ):
71
+ verbose_option = click.Option(
72
+ ["--verbose"],
73
+ is_flag=True,
74
+ help="Enable verbose output.",
75
+ # expose_value=True is default, making 'verbose' a kwarg to the callback
76
+ )
77
+ command_to_wrap.params.append(verbose_option)
78
+
79
+ return command_to_wrap # Return the modified command
multi/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ class NoRepositoriesError(Exception):
2
+ pass
3
+
4
+
5
+ class GitError(Exception):
6
+ pass
7
+
8
+
9
+ class RepoNotCleanError(GitError):
10
+ pass
11
+
12
+
13
+ class RulesError(Exception):
14
+ pass
15
+
16
+
17
+ class RuleParseError(RulesError):
18
+ pass
19
+
20
+
21
+ class RulesNotCombinableError(RulesError):
22
+ pass
multi/git_helpers.py ADDED
@@ -0,0 +1,109 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Tuple
4
+
5
+ import git
6
+ from git.exc import InvalidGitRepositoryError
7
+
8
+ from multi.errors import GitError, RepoNotCleanError
9
+ from multi.paths import Paths
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def is_git_repo_root(repo_path: Path) -> bool:
15
+ # Will fail for submodules and worktrees, but these aren't used by us
16
+ return (repo_path / ".git").is_dir()
17
+
18
+
19
+ def get_current_branch(repo_path: Path) -> str:
20
+ """Get the current branch name of a git repository."""
21
+ try:
22
+ repo = git.Repo(repo_path)
23
+ return repo.active_branch.name
24
+ except InvalidGitRepositoryError as e:
25
+ logger.error("Failed to determine current branch")
26
+ raise GitError("Failed to determine current branch") from e
27
+ except TypeError:
28
+ # Detached HEAD state - active_branch raises TypeError
29
+ return "HEAD"
30
+
31
+
32
+ def check_all_on_same_branch(paths: Paths, raise_error: bool = True) -> bool:
33
+ """Validate that all repositories are on the same branch."""
34
+ from multi.repos import load_repos
35
+
36
+ root_branch = get_current_branch(paths.root_dir)
37
+ repo_branches = [
38
+ (repo, get_current_branch(repo.path)) for repo in load_repos(paths)
39
+ ]
40
+ for repo, branch in repo_branches:
41
+ if branch != root_branch:
42
+ if raise_error:
43
+ raise GitError(
44
+ f"Repository {repo.name} is not on the same branch as the root repository. Please fix. {repo.name}: {branch}, Root: {root_branch}"
45
+ )
46
+ return False
47
+ return True
48
+
49
+
50
+ def check_repo_is_clean(repo_path: Path, raise_error: bool = True) -> bool:
51
+ # Check if this is a git repository
52
+ if not is_git_repo_root(repo_path):
53
+ raise GitError(
54
+ f"{repo_path} is not a git repository or has not been initialized properly (no .git folder)"
55
+ )
56
+
57
+ # Make sure we have a clean working directory
58
+ try:
59
+ repo = git.Repo(repo_path)
60
+ # is_dirty checks for modified/staged files, untracked_files checks for new files
61
+ is_clean = not repo.is_dirty(untracked_files=True)
62
+ except InvalidGitRepositoryError as e:
63
+ logger.error("Failed to check working directory status")
64
+ raise GitError("Failed to check working directory status") from e
65
+
66
+ if not is_clean:
67
+ if raise_error:
68
+ raise RepoNotCleanError(
69
+ f"Working directory is not clean in {repo_path}. Please commit or stash changes first."
70
+ )
71
+ return False
72
+ return True
73
+
74
+
75
+ def check_all_repos_are_clean(paths: Paths, raise_error: bool = True) -> bool:
76
+ """Check if all repositories are clean."""
77
+ from multi.repos import load_repos
78
+
79
+ # Check root repo
80
+ if not check_repo_is_clean(paths.root_dir, raise_error):
81
+ return False
82
+
83
+ # Check sub-repos
84
+ return all(
85
+ check_repo_is_clean(repo.path, raise_error) for repo in load_repos(paths)
86
+ )
87
+
88
+
89
+ def check_branch_existence(repo_path: Path, branch_name: str) -> Tuple[bool, bool]:
90
+ try:
91
+ repo = git.Repo(repo_path)
92
+ except InvalidGitRepositoryError as e:
93
+ logger.error("Failed to check if branch exists")
94
+ raise GitError("Failed to check if branch exists") from e
95
+
96
+ # Check if branch exists locally
97
+ exists_locally = branch_name in [head.name for head in repo.heads]
98
+
99
+ # Check if branch exists remotely
100
+ try:
101
+ remote_refs = [ref.name for ref in repo.remotes.origin.refs]
102
+ exists_remotely = f"origin/{branch_name}" in remote_refs
103
+ except Exception:
104
+ logger.debug(
105
+ f"Could not check remote branches in {repo_path}, assuming branch doesn't exist remotely"
106
+ )
107
+ exists_remotely = False
108
+
109
+ return exists_locally, exists_remotely
multi/git_run.py ADDED
@@ -0,0 +1,59 @@
1
+ import logging
2
+ import subprocess
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ import click
7
+
8
+ from multi.errors import GitError
9
+ from multi.git_helpers import check_all_on_same_branch
10
+ from multi.paths import Paths
11
+ from multi.repos import load_repos
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def run_git_command(repo_path: Path, git_args: List[str]) -> None:
17
+ """Run a git command in the specified repository."""
18
+ command_str = " ".join(git_args)
19
+ logger.info(f"Running 'git {command_str}' in {repo_path}")
20
+
21
+ cmd = ["git"] + git_args
22
+ try:
23
+ outputs = subprocess.run(
24
+ cmd,
25
+ cwd=repo_path,
26
+ check=check,
27
+ capture_output=True,
28
+ text=True,
29
+ ).stdout.strip()
30
+ logger.info(f"Output from {repo_path}:\n{outputs}")
31
+ except subprocess.CalledProcessError as e:
32
+ logger.error(f"Failed to run git command in {repo_path}")
33
+ raise GitError(f"Failed to run git command in {repo_path}") from e
34
+
35
+
36
+ def run_git_in_all_repos(paths: Paths, git_args: List[str]) -> None:
37
+ """Run git command across all repositories."""
38
+ # First check if all repos are on the same branch
39
+ check_all_on_same_branch(raise_error=True)
40
+
41
+ # Run in root repo first
42
+ run_git_command(paths.root_dir, git_args)
43
+
44
+ # Then run in all sub-repos
45
+ for repo in load_repos(paths.settings):
46
+ run_git_command(repo.path, git_args)
47
+
48
+
49
+ @click.command(name="git")
50
+ @click.argument("git_args", nargs=-1, required=True)
51
+ def git_cmd(git_args: tuple[str, ...]) -> None:
52
+ """Run a git command across all repositories.
53
+
54
+ GIT_ARGS: The git command and arguments to run (e.g. 'pull' or 'checkout main')
55
+
56
+ Example: multi git pull
57
+ multi git checkout -b feature/new-branch
58
+ """
59
+ run_git_in_all_repos(list(git_args))
@@ -0,0 +1,66 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ import click
5
+ import git
6
+
7
+ from multi.errors import GitError
8
+ from multi.git_helpers import (
9
+ check_all_on_same_branch,
10
+ check_all_repos_are_clean,
11
+ check_branch_existence,
12
+ )
13
+ from multi.paths import Paths
14
+ from multi.repos import load_repos
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def create_and_switch_branch(
20
+ repo_path: Path, branch_name: str, allow_create: bool = True
21
+ ) -> None:
22
+ """Create a branch if it doesn't exist and switch to it."""
23
+ repo = git.Repo(repo_path)
24
+
25
+ # Check if branch exists locally or remotely
26
+ exists_locally, exists_remotely = check_branch_existence(repo_path, branch_name)
27
+
28
+ if exists_locally or exists_remotely:
29
+ logger.info(f"Branch '{branch_name}' already exists in {repo_path}")
30
+ repo.git.checkout(branch_name)
31
+ else:
32
+ if not allow_create:
33
+ raise GitError(
34
+ f"Branch '{branch_name}' does not exist in {repo_path}. Normally we would create a new branch, but you started with different repos checked out to different branches, so there is no base branch to create from."
35
+ )
36
+ # Create a new branch from current HEAD
37
+ repo.create_head(branch_name).checkout()
38
+ logger.info(f"✅ Switched to branch '{branch_name}' in {repo_path}")
39
+
40
+
41
+ def set_branch_in_all_repos(root_dir: Path, branch_name: str) -> None:
42
+ paths = Paths(root_dir)
43
+ check_all_repos_are_clean(paths=paths, raise_error=True)
44
+ all_on_same_branch = check_all_on_same_branch(paths=paths, raise_error=False)
45
+ if not all_on_same_branch:
46
+ logger.warning(
47
+ "Some repos are not on the same branch as the root repo. If the branch already exists for all repos, this command will fix the situation."
48
+ )
49
+
50
+ create_and_switch_branch(
51
+ paths.root_dir, branch_name, allow_create=all_on_same_branch
52
+ )
53
+ for repo in load_repos(paths=paths):
54
+ create_and_switch_branch(
55
+ repo.path, branch_name, allow_create=all_on_same_branch
56
+ )
57
+
58
+
59
+ @click.command(name="set-branch")
60
+ @click.argument("branch_name")
61
+ def set_branch_cmd(branch_name: str) -> None:
62
+ """Create and switch to a branch in all repositories.
63
+
64
+ BRANCH_NAME: Name of the branch to create and switch to
65
+ """
66
+ set_branch_in_all_repos(root_dir=Path.cwd(), branch_name=branch_name)
multi/ignore_files.py ADDED
@@ -0,0 +1,104 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import List, Optional
4
+
5
+ from multi.paths import Paths
6
+ from multi.repos import load_repos
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class IgnoreFile:
12
+ def __init__(self, path: Path):
13
+ self.path = path
14
+ self._existing_lines: Optional[List[str]] = None
15
+
16
+ @property
17
+ def existing_lines(self) -> List[str]:
18
+ """Lazily load and cache existing lines from the file."""
19
+ if self._existing_lines is None:
20
+ self._existing_lines = self._read_lines()
21
+ return self._existing_lines
22
+
23
+ def _read_lines(self) -> List[str]:
24
+ """Read and return lines from the ignore file."""
25
+ if not self.path.exists():
26
+ return []
27
+ with self.path.open("r") as f:
28
+ return [line.strip() for line in f.readlines()]
29
+
30
+ def add_lines_if_missing(self, lines: List[str], header: str) -> None:
31
+ """Add lines under the specified header section, creating it if needed.
32
+
33
+ If the header exists, new lines are added under the existing section.
34
+ If the header doesn't exist, it's added to the bottom of the file.
35
+ Only lines that don't already exist in the file are added.
36
+ """
37
+ if not lines:
38
+ return
39
+
40
+ # Find lines that don't already exist
41
+ lines_to_add = [line for line in lines if line not in self.existing_lines]
42
+ if not lines_to_add:
43
+ return
44
+
45
+ existing_lines = self.existing_lines.copy()
46
+
47
+ # Try to find the header in existing content
48
+ try:
49
+ header_index = existing_lines.index(header)
50
+ # Find the end of this section (next header or end of file)
51
+ section_end = header_index + 1
52
+ while section_end < len(existing_lines):
53
+ if existing_lines[section_end].startswith("#"):
54
+ break
55
+ section_end += 1
56
+ # Insert new lines after the last line in this section
57
+ existing_lines[section_end:section_end] = lines_to_add
58
+ except ValueError:
59
+ # Header not found, add to end of file
60
+ if existing_lines and existing_lines[-1] != "":
61
+ existing_lines.append("") # Add blank line before new section
62
+ existing_lines.append(header)
63
+ existing_lines.extend(lines_to_add)
64
+
65
+ # Write back the updated content
66
+ with self.path.open("w") as f:
67
+ f.write("\n".join(existing_lines) + "\n")
68
+
69
+ # Update cached lines
70
+ self._existing_lines = existing_lines
71
+
72
+
73
+ def update_gitignore_with_repos(paths: Paths):
74
+ """Ensure all repos are in gitignore entries."""
75
+ repos = load_repos(paths=paths)
76
+ repo_entries = [f"{repo.name}/" for repo in repos]
77
+ gitignore = IgnoreFile(paths.gitignore_path)
78
+ gitignore.add_lines_if_missing(repo_entries, "# Ignore repository directories")
79
+ logger.debug("Updated .gitignore with new repositories")
80
+
81
+
82
+ def update_ignore_with_repos(paths: Paths):
83
+ """Update .ignore to allow searching in gitignored directories."""
84
+ repos = load_repos(paths=paths)
85
+ repo_entries = [f"!{repo.name}/" for repo in repos]
86
+ vscode_ignore = IgnoreFile(paths.vscode_ignore_path)
87
+ vscode_ignore.add_lines_if_missing(
88
+ repo_entries,
89
+ "# Allow us to search inside these gitignored directories",
90
+ )
91
+ logger.debug("Updated .ignore with new repositories")
92
+
93
+
94
+ def update_gitignore_with_vscode_files(paths: Paths):
95
+ """Add VS Code generated configuration files to gitignore entries."""
96
+ vscode_entries = [
97
+ ".vscode/launch.json",
98
+ ".vscode/settings.json",
99
+ ".vscode/tasks.json",
100
+ ".vscode/extensions.json",
101
+ ]
102
+ gitignore = IgnoreFile(paths.gitignore_path)
103
+ gitignore.add_lines_if_missing(vscode_entries, "# Generated files")
104
+ logger.debug("Updated .gitignore with VS Code configuration files")