multi-workspace 3.0.0__tar.gz

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.
Files changed (32) hide show
  1. multi_workspace-3.0.0/.gitignore +185 -0
  2. multi_workspace-3.0.0/LICENSE +9 -0
  3. multi_workspace-3.0.0/PKG-INFO +57 -0
  4. multi_workspace-3.0.0/README.md +32 -0
  5. multi_workspace-3.0.0/multi/__init__.py +1 -0
  6. multi_workspace-3.0.0/multi/__main__.py +4 -0
  7. multi_workspace-3.0.0/multi/_version.py +34 -0
  8. multi_workspace-3.0.0/multi/cli.py +45 -0
  9. multi_workspace-3.0.0/multi/cli_helpers.py +79 -0
  10. multi_workspace-3.0.0/multi/errors.py +22 -0
  11. multi_workspace-3.0.0/multi/git_helpers.py +109 -0
  12. multi_workspace-3.0.0/multi/git_run.py +59 -0
  13. multi_workspace-3.0.0/multi/git_set_branch.py +66 -0
  14. multi_workspace-3.0.0/multi/ignore_files.py +104 -0
  15. multi_workspace-3.0.0/multi/init.py +193 -0
  16. multi_workspace-3.0.0/multi/logging.py +32 -0
  17. multi_workspace-3.0.0/multi/paths.py +85 -0
  18. multi_workspace-3.0.0/multi/repos.py +92 -0
  19. multi_workspace-3.0.0/multi/resources/init_readme.md +11 -0
  20. multi_workspace-3.0.0/multi/rules.py +100 -0
  21. multi_workspace-3.0.0/multi/settings.py +35 -0
  22. multi_workspace-3.0.0/multi/sync.py +89 -0
  23. multi_workspace-3.0.0/multi/sync_claude.py +88 -0
  24. multi_workspace-3.0.0/multi/sync_ruff.py +85 -0
  25. multi_workspace-3.0.0/multi/sync_vscode.py +51 -0
  26. multi_workspace-3.0.0/multi/sync_vscode_extensions.py +52 -0
  27. multi_workspace-3.0.0/multi/sync_vscode_helpers.py +169 -0
  28. multi_workspace-3.0.0/multi/sync_vscode_launch.py +105 -0
  29. multi_workspace-3.0.0/multi/sync_vscode_settings.py +100 -0
  30. multi_workspace-3.0.0/multi/sync_vscode_tasks.py +93 -0
  31. multi_workspace-3.0.0/multi/utils.py +163 -0
  32. multi_workspace-3.0.0/pyproject.toml +65 -0
@@ -0,0 +1,185 @@
1
+ CLAUDE.md
2
+ .data/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+ # C extensions
10
+ *.so
11
+
12
+ # Distribution / packaging
13
+ .Python
14
+ build/
15
+ develop-eggs/
16
+ dist/
17
+ downloads/
18
+ eggs/
19
+ .eggs/
20
+ lib/
21
+ lib64/
22
+ parts/
23
+ sdist/
24
+ var/
25
+ wheels/
26
+ share/python-wheels/
27
+ *.egg-info/
28
+ .installed.cfg
29
+ *.egg
30
+ MANIFEST
31
+
32
+ # PyInstaller
33
+ # Usually these files are written by a python script from a template
34
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
35
+ *.manifest
36
+ *.spec
37
+
38
+ # Installer logs
39
+ pip-log.txt
40
+ pip-delete-this-directory.txt
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ *.py,cover
53
+ .hypothesis/
54
+ .pytest_cache/
55
+ cover/
56
+
57
+ # Translations
58
+ *.mo
59
+ *.pot
60
+
61
+ # Django stuff:
62
+ *.log
63
+ local_settings.py
64
+ db.sqlite3
65
+ db.sqlite3-journal
66
+
67
+ # Flask stuff:
68
+ instance/
69
+ .webassets-cache
70
+
71
+ # Scrapy stuff:
72
+ .scrapy
73
+
74
+ # Sphinx documentation
75
+ docs/_build/
76
+
77
+ # PyBuilder
78
+ .pybuilder/
79
+ target/
80
+
81
+ # Jupyter Notebook
82
+ .ipynb_checkpoints
83
+
84
+ # IPython
85
+ profile_default/
86
+ ipython_config.py
87
+
88
+ # pyenv
89
+ # For a library or package, you might want to ignore these files since the code is
90
+ # intended to run in multiple environments; otherwise, check them in:
91
+ # .python-version
92
+
93
+ # pipenv
94
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
95
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
96
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
97
+ # install all needed dependencies.
98
+ #Pipfile.lock
99
+
100
+ # poetry
101
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
102
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
103
+ # commonly ignored for libraries.
104
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
105
+ #poetry.lock
106
+
107
+ # pdm
108
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
109
+ #pdm.lock
110
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
111
+ # in version control.
112
+ # https://pdm.fming.dev/#use-with-ide
113
+ .pdm.toml
114
+
115
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
116
+ __pypackages__/
117
+
118
+ # Celery stuff
119
+ celerybeat-schedule
120
+ celerybeat.pid
121
+
122
+ # SageMath parsed files
123
+ *.sage.py
124
+
125
+ # Environments
126
+ .env
127
+ .env.gha
128
+ .venv
129
+ env/
130
+ venv/
131
+ ENV/
132
+ env.bak/
133
+ venv.bak/
134
+
135
+ # Spyder project settings
136
+ .spyderproject
137
+ .spyproject
138
+
139
+ # Rope project settings
140
+ .ropeproject
141
+
142
+ # mkdocs documentation
143
+ /site
144
+
145
+ # mypy
146
+ .mypy_cache/
147
+ .dmypy.json
148
+ dmypy.json
149
+
150
+ # Pyre type checker
151
+ .pyre/
152
+
153
+ # pytype static type analyzer
154
+ .pytype/
155
+
156
+ # Cython debug symbols
157
+ cython_debug/
158
+
159
+ # Ruff stuff:
160
+ .ruff_cache/
161
+
162
+ # PyPI configuration file
163
+ .pypirc
164
+
165
+ # Virtual Environment
166
+ .env
167
+ .venv
168
+ env/
169
+ venv/
170
+ ENV/
171
+
172
+ # IDE
173
+ .idea/
174
+ # .vscode/
175
+ *.swp
176
+ *.swo
177
+
178
+ # OS
179
+ .DS_Store
180
+ Thumbs.db
181
+
182
+ staticfiles
183
+
184
+ .env
185
+ _version.py
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Gabriel Montague
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: multi-workspace
3
+ Version: 3.0.0
4
+ Summary: Multi
5
+ Project-URL: Repository, https://github.com/gabemontague/multi
6
+ Project-URL: Issues, https://github.com/gabemontague/multi/issues
7
+ Author-email: Gabe Montague <gabemontague@outlook.com>
8
+ License-File: LICENSE
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Version Control :: Git
15
+ Requires-Python: >=3.9
16
+ Requires-Dist: click>=8.3.1
17
+ Requires-Dist: gitpython>=3.1.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pyinstaller>=6.9.0; extra == 'dev'
20
+ Requires-Dist: pytest-cov>=6.1.1; extra == 'dev'
21
+ Requires-Dist: pytest-mock>=3.14.0; extra == 'dev'
22
+ Requires-Dist: pytest>=8.3.5; extra == 'dev'
23
+ Requires-Dist: ruff>=0.11.10; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # multi
27
+
28
+ `multi` is the best way to work with VS Code/Cursor on multiple Git repos at once. It is an alternative to [multi-root workspaces](https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces) that offers more flexibility and control. With `multi`, you can gain control over how tasks, debug runnables, and various IDE and linter settings are combined from multiple project repos ("sub-repos") located in the same folder.
29
+
30
+ Features:
31
+
32
+ - Generates files in your root `.vscode` folder from sub-repo `launch.json`, `tasks.json`, and `settings.json` files.
33
+ - Generates `CLAUDE.md` files from Cursor rules.
34
+
35
+ ## Installation
36
+
37
+ ### Using `pipx`:
38
+
39
+ - Install [pipx](https://github.com/pypa/pipx)
40
+ - Run `pipx install multi-sync`
41
+
42
+ ### Using `uv`
43
+
44
+ - Install [uv](https://docs.astral.sh/uv/getting-started/installation/)
45
+ - Run `uv tool install multi-sync`
46
+
47
+ ## Getting started
48
+
49
+ To get started, create a new workspace directory that will house all your related repos and run:
50
+
51
+ ```
52
+ multi init
53
+ ```
54
+
55
+ When prompted, paste in the URLs of all the repositories you want to have in your workspace. You can optionally specify descriptions of what they do, which will be used to create a new repo-directories.mdc Cursor/Claude rule.
56
+
57
+ It is recommended you also install the [VS Code Extension](https://marketplace.visualstudio.com/items?itemName=montaguegabe.multi-sync) that automatically keeps your project synced when edits are made to synced files. To manually sync, you can run `multi sync`.
@@ -0,0 +1,32 @@
1
+ # multi
2
+
3
+ `multi` is the best way to work with VS Code/Cursor on multiple Git repos at once. It is an alternative to [multi-root workspaces](https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces) that offers more flexibility and control. With `multi`, you can gain control over how tasks, debug runnables, and various IDE and linter settings are combined from multiple project repos ("sub-repos") located in the same folder.
4
+
5
+ Features:
6
+
7
+ - Generates files in your root `.vscode` folder from sub-repo `launch.json`, `tasks.json`, and `settings.json` files.
8
+ - Generates `CLAUDE.md` files from Cursor rules.
9
+
10
+ ## Installation
11
+
12
+ ### Using `pipx`:
13
+
14
+ - Install [pipx](https://github.com/pypa/pipx)
15
+ - Run `pipx install multi-sync`
16
+
17
+ ### Using `uv`
18
+
19
+ - Install [uv](https://docs.astral.sh/uv/getting-started/installation/)
20
+ - Run `uv tool install multi-sync`
21
+
22
+ ## Getting started
23
+
24
+ To get started, create a new workspace directory that will house all your related repos and run:
25
+
26
+ ```
27
+ multi init
28
+ ```
29
+
30
+ When prompted, paste in the URLs of all the repositories you want to have in your workspace. You can optionally specify descriptions of what they do, which will be used to create a new repo-directories.mdc Cursor/Claude rule.
31
+
32
+ It is recommended you also install the [VS Code Extension](https://marketplace.visualstudio.com/items?itemName=montaguegabe.multi-sync) that automatically keeps your project synced when edits are made to synced files. To manually sync, you can run `multi sync`.
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,4 @@
1
+ from multi.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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
@@ -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()
@@ -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
@@ -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
@@ -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
@@ -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))