multi-workspace 3.2.12__tar.gz → 3.2.13__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.
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/PKG-INFO +2 -2
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/_version.py +2 -2
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/branch.py +30 -14
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/cli_helpers.py +1 -1
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/git_helpers.py +28 -6
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/git_run.py +3 -3
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/git_set_branch.py +4 -2
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/ignore_files.py +13 -5
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/remove.py +8 -3
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/repos.py +27 -1
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync.py +21 -15
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_devcontainer.py +1 -1
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_helpers.py +3 -1
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_launch.py +1 -1
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_settings.py +2 -4
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/worktree.py +5 -2
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/.gitignore +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/LICENSE +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/README.md +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/__init__.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/__main__.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/add.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/api.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/app_api.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/bootstrap.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/cli.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/collaborator.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/convert_monorepo.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/doctor.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/errors.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/init.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/logging.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/managed_blocks.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/open.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/paths.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/registry.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/repo_urls.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/resources/init_readme.md +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/service.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/settings.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_agents.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_github.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_extensions.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_tasks.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/utils.py +0 -0
- {multi_workspace-3.2.12 → multi_workspace-3.2.13}/pyproject.toml +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '3.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (3, 2,
|
|
21
|
+
__version__ = version = '3.2.13'
|
|
22
|
+
__version_tuple__ = version_tuple = (3, 2, 13)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -3,8 +3,13 @@ from pathlib import Path
|
|
|
3
3
|
|
|
4
4
|
import click
|
|
5
5
|
|
|
6
|
+
from multi.cli_helpers import common_command_wrapper
|
|
6
7
|
from multi.errors import GitError
|
|
7
|
-
from multi.git_helpers import
|
|
8
|
+
from multi.git_helpers import (
|
|
9
|
+
describe_head,
|
|
10
|
+
expected_branch_description,
|
|
11
|
+
expected_branch_for_repo,
|
|
12
|
+
)
|
|
8
13
|
from multi.paths import Paths
|
|
9
14
|
from multi.repos import load_repos
|
|
10
15
|
|
|
@@ -36,27 +41,17 @@ def report_branches(paths: Paths) -> bool:
|
|
|
36
41
|
all_match = False
|
|
37
42
|
logger.warning(f"{repo.name}: (missing — run `multi sync`)")
|
|
38
43
|
continue
|
|
39
|
-
expected_branch = repo
|
|
44
|
+
expected_branch = expected_branch_for_repo(repo, root_branch)
|
|
40
45
|
if branch == expected_branch:
|
|
41
46
|
logger.info(f"{repo.name}: {branch}")
|
|
42
47
|
else:
|
|
43
48
|
all_match = False
|
|
44
|
-
|
|
45
|
-
expectation = f"fixed branch {expected_branch}"
|
|
46
|
-
else:
|
|
47
|
-
expectation = f"root branch {root_branch}"
|
|
49
|
+
expectation = expected_branch_description(repo, root_branch)
|
|
48
50
|
logger.warning(f"{repo.name}: {branch} (expected {expectation})")
|
|
49
51
|
return all_match
|
|
50
52
|
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
def branch_cmd() -> None:
|
|
54
|
-
"""Show the current branch of the root repo and every sub-repo.
|
|
55
|
-
|
|
56
|
-
Read-only: unlike `multi set-branch` and `multi git`, this works with
|
|
57
|
-
dirty working trees, mismatched branches, and detached HEADs.
|
|
58
|
-
Exits with an error status when repos are not on their expected branches.
|
|
59
|
-
"""
|
|
54
|
+
def _check_branch_alignment() -> None:
|
|
60
55
|
paths = Paths(Path.cwd())
|
|
61
56
|
if not report_branches(paths):
|
|
62
57
|
raise GitError(
|
|
@@ -64,3 +59,24 @@ def branch_cmd() -> None:
|
|
|
64
59
|
"Run `multi sync` to clone missing repos, or `multi set-branch` "
|
|
65
60
|
"(with clean working trees) to fix branch mismatches."
|
|
66
61
|
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@click.group(name="branch", invoke_without_command=True)
|
|
65
|
+
@click.pass_context
|
|
66
|
+
def branch_cmd(ctx: click.Context) -> None:
|
|
67
|
+
"""Show branch state and expected-branch alignment.
|
|
68
|
+
|
|
69
|
+
Running `multi branch` is kept as the backwards-compatible spelling for
|
|
70
|
+
`multi branch check`.
|
|
71
|
+
"""
|
|
72
|
+
if ctx.invoked_subcommand is None:
|
|
73
|
+
_check_branch_alignment()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@click.command(name="check")
|
|
77
|
+
def branch_check_cmd() -> None:
|
|
78
|
+
"""Check that repos are on their expected branches."""
|
|
79
|
+
_check_branch_alignment()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
branch_cmd.add_command(common_command_wrapper(branch_check_cmd))
|
|
@@ -67,7 +67,7 @@ def common_command_wrapper(command_to_wrap: click.Command) -> click.Command:
|
|
|
67
67
|
if ctx.invoked_subcommand is not None:
|
|
68
68
|
return result
|
|
69
69
|
|
|
70
|
-
# After commands, check that all sub-repos are on
|
|
70
|
+
# After commands, check that all sub-repos are on their expected branch.
|
|
71
71
|
# Some commands (like doctor) intentionally run even when no workspace is initialized.
|
|
72
72
|
if command_to_wrap.name in COMMANDS_WITHOUT_WORKSPACE_BRANCH_CHECK:
|
|
73
73
|
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import Tuple
|
|
3
|
+
from typing import Any, Tuple
|
|
4
4
|
|
|
5
5
|
import git
|
|
6
6
|
from git.exc import InvalidGitRepositoryError, NoSuchPathError
|
|
@@ -11,6 +11,31 @@ from multi.paths import Paths
|
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def expected_branch_for_repo(repo: Any, workspace_branch: str | None) -> str | None:
|
|
15
|
+
"""Return the branch a sub-repo should be on.
|
|
16
|
+
|
|
17
|
+
This is the fixed-branch invariant for Multi workspaces: a repo's
|
|
18
|
+
``fixedBranch`` config wins, otherwise it follows the workspace branch.
|
|
19
|
+
Commands that need branch expectations should use this helper instead of
|
|
20
|
+
reading ``repo.fixed_branch`` directly.
|
|
21
|
+
"""
|
|
22
|
+
fixed_branch = getattr(repo, "fixed_branch", None)
|
|
23
|
+
if fixed_branch is not None:
|
|
24
|
+
return fixed_branch
|
|
25
|
+
return workspace_branch
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def repo_uses_fixed_branch(repo: Any) -> bool:
|
|
29
|
+
return getattr(repo, "fixed_branch", None) is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def expected_branch_description(repo: Any, workspace_branch: str | None) -> str:
|
|
33
|
+
expected_branch = expected_branch_for_repo(repo, workspace_branch)
|
|
34
|
+
if repo_uses_fixed_branch(repo):
|
|
35
|
+
return f"fixed branch {expected_branch}"
|
|
36
|
+
return f"root branch {workspace_branch}"
|
|
37
|
+
|
|
38
|
+
|
|
14
39
|
def is_git_repo_root(repo_path: Path) -> bool:
|
|
15
40
|
git_metadata_path = repo_path / ".git"
|
|
16
41
|
if not git_metadata_path.exists():
|
|
@@ -70,13 +95,10 @@ def check_all_on_same_branch(paths: Paths, raise_error: bool = True) -> bool:
|
|
|
70
95
|
root_branch = describe_head(paths.root_dir)
|
|
71
96
|
repo_branches = [(repo, describe_head(repo.path)) for repo in load_repos(paths)]
|
|
72
97
|
for repo, branch in repo_branches:
|
|
73
|
-
expected_branch = repo
|
|
98
|
+
expected_branch = expected_branch_for_repo(repo, root_branch)
|
|
74
99
|
if branch != expected_branch:
|
|
75
100
|
if raise_error:
|
|
76
|
-
|
|
77
|
-
expectation = f"fixed branch {expected_branch}"
|
|
78
|
-
else:
|
|
79
|
-
expectation = f"root branch {root_branch}"
|
|
101
|
+
expectation = expected_branch_description(repo, root_branch)
|
|
80
102
|
raise GitError(
|
|
81
103
|
f"Repository {repo.name} is not on the expected branch. "
|
|
82
104
|
f"Please fix. {repo.name}: {branch}, Expected: {expectation}"
|
|
@@ -35,7 +35,7 @@ def run_git_command(repo_path: Path, git_args: List[str]) -> None:
|
|
|
35
35
|
|
|
36
36
|
def run_git_in_all_repos(paths: Paths, git_args: List[str]) -> None:
|
|
37
37
|
"""Run git command across all repositories."""
|
|
38
|
-
# First check if all repos are on
|
|
38
|
+
# First check if all repos are on their expected branch.
|
|
39
39
|
check_all_on_same_branch(paths=paths, raise_error=True)
|
|
40
40
|
|
|
41
41
|
# Run in root repo first
|
|
@@ -57,8 +57,8 @@ def git_cmd(git_args: tuple[str, ...]) -> None:
|
|
|
57
57
|
multi git checkout -b feature/new-branch
|
|
58
58
|
multi git branch --show-current
|
|
59
59
|
|
|
60
|
-
Requires all repositories to be on
|
|
61
|
-
dirty). To inspect branches when they mismatch, use `multi branch`.
|
|
60
|
+
Requires all repositories to be on their expected branch (working trees may
|
|
61
|
+
be dirty). To inspect branches when they mismatch, use `multi branch`.
|
|
62
62
|
"""
|
|
63
63
|
paths = Paths(Path.cwd())
|
|
64
64
|
if paths.settings.is_monorepo():
|
|
@@ -9,6 +9,7 @@ from multi.git_helpers import (
|
|
|
9
9
|
check_all_on_same_branch,
|
|
10
10
|
check_all_repos_are_clean,
|
|
11
11
|
check_branch_existence,
|
|
12
|
+
expected_branch_for_repo,
|
|
12
13
|
)
|
|
13
14
|
from multi.paths import Paths
|
|
14
15
|
from multi.repos import load_repos
|
|
@@ -44,14 +45,15 @@ def set_branch_in_all_repos(root_dir: Path, branch_name: str) -> None:
|
|
|
44
45
|
all_on_same_branch = check_all_on_same_branch(paths=paths, raise_error=False)
|
|
45
46
|
if not all_on_same_branch:
|
|
46
47
|
logger.warning(
|
|
47
|
-
"Some repos are not on
|
|
48
|
+
"Some repos are not on their expected branches. If the target branches already exist for all repos, this command will fix the situation."
|
|
48
49
|
)
|
|
49
50
|
|
|
50
51
|
create_and_switch_branch(
|
|
51
52
|
paths.root_dir, branch_name, allow_create=all_on_same_branch
|
|
52
53
|
)
|
|
53
54
|
for repo in load_repos(paths=paths):
|
|
54
|
-
repo_branch_name = repo
|
|
55
|
+
repo_branch_name = expected_branch_for_repo(repo, branch_name)
|
|
56
|
+
assert repo_branch_name is not None
|
|
55
57
|
create_and_switch_branch(
|
|
56
58
|
repo.path, repo_branch_name, allow_create=all_on_same_branch
|
|
57
59
|
)
|
|
@@ -55,11 +55,11 @@ class IgnoreFile:
|
|
|
55
55
|
|
|
56
56
|
def _repo_entries(paths: Paths) -> List[str]:
|
|
57
57
|
entries: List[str] = [
|
|
58
|
-
"# multi avoids git submodules; each entry
|
|
58
|
+
"# multi avoids git submodules; the leading slash anchors each entry to the workspace root so it never matches a nested directory of the same name (e.g. .agents/skills). The trailing-slash form matches the repo directory; the bare form matches a root symlink/alias.",
|
|
59
59
|
]
|
|
60
60
|
for repo in load_repos(paths=paths):
|
|
61
|
-
entries.append(f"{repo.
|
|
62
|
-
entries.append(repo.
|
|
61
|
+
entries.append(f"/{repo.relative_path}/")
|
|
62
|
+
entries.append(f"/{repo.relative_path}")
|
|
63
63
|
return entries
|
|
64
64
|
|
|
65
65
|
|
|
@@ -68,8 +68,8 @@ def _search_entries(paths: Paths) -> List[str]:
|
|
|
68
68
|
"# Allow us to search inside these gitignored directories",
|
|
69
69
|
]
|
|
70
70
|
for repo in load_repos(paths=paths):
|
|
71
|
-
entries.append(f"
|
|
72
|
-
entries.append(f"
|
|
71
|
+
entries.append(f"!/{repo.relative_path}/")
|
|
72
|
+
entries.append(f"!/{repo.relative_path}")
|
|
73
73
|
return entries
|
|
74
74
|
|
|
75
75
|
|
|
@@ -117,6 +117,10 @@ def remove_gitignore_entries_for_repos(paths: Paths, repo_names: List[str]) -> N
|
|
|
117
117
|
|
|
118
118
|
entries = []
|
|
119
119
|
for repo_name in repo_names:
|
|
120
|
+
# Remove both the anchored form and the legacy unanchored form so
|
|
121
|
+
# workspaces written by older multi versions migrate cleanly.
|
|
122
|
+
entries.append(f"/{repo_name}/")
|
|
123
|
+
entries.append(f"/{repo_name}")
|
|
120
124
|
entries.append(f"{repo_name}/")
|
|
121
125
|
entries.append(repo_name)
|
|
122
126
|
|
|
@@ -131,6 +135,10 @@ def remove_ignore_entries_for_repos(paths: Paths, repo_names: List[str]) -> None
|
|
|
131
135
|
|
|
132
136
|
entries = []
|
|
133
137
|
for repo_name in repo_names:
|
|
138
|
+
# Remove both the anchored form and the legacy unanchored form so
|
|
139
|
+
# workspaces written by older multi versions migrate cleanly.
|
|
140
|
+
entries.append(f"!/{repo_name}/")
|
|
141
|
+
entries.append(f"!/{repo_name}")
|
|
134
142
|
entries.append(f"!{repo_name}/")
|
|
135
143
|
entries.append(f"!{repo_name}")
|
|
136
144
|
|
|
@@ -52,8 +52,13 @@ def remove_cmd(repo_name: str, delete: bool, force: bool):
|
|
|
52
52
|
if matched_index is None:
|
|
53
53
|
raise click.ClickException(f"No repo named '{repo_name}' found in multi.json.")
|
|
54
54
|
|
|
55
|
+
# Resolve the repo's on-disk location, honoring a custom "path" (nested
|
|
56
|
+
# layout); fall back to the repo name (flat layout).
|
|
57
|
+
matched_entry = repos_list[matched_index]
|
|
58
|
+
relative_path = (matched_entry.get("path") or repo_name).strip().strip("/")
|
|
59
|
+
|
|
55
60
|
# Handle --delete
|
|
56
|
-
repo_path = paths.root_dir /
|
|
61
|
+
repo_path = paths.root_dir / relative_path
|
|
57
62
|
if delete and repo_path.exists():
|
|
58
63
|
if not force and not is_monorepo:
|
|
59
64
|
# In monorepo mode there's no .git to check; in normal mode, check cleanliness
|
|
@@ -81,8 +86,8 @@ def remove_cmd(repo_name: str, delete: bool, force: bool):
|
|
|
81
86
|
|
|
82
87
|
# Clean up ignore files (not used in monorepo mode)
|
|
83
88
|
if not is_monorepo:
|
|
84
|
-
remove_gitignore_entries_for_repos(paths, [
|
|
85
|
-
remove_ignore_entries_for_repos(paths, [
|
|
89
|
+
remove_gitignore_entries_for_repos(paths, [relative_path])
|
|
90
|
+
remove_ignore_entries_for_repos(paths, [relative_path])
|
|
86
91
|
if repo_path.exists():
|
|
87
92
|
clear_subrepo_generated_file_block(repo_path)
|
|
88
93
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from pathlib import Path, PurePosixPath
|
|
1
2
|
from typing import Any, List
|
|
2
3
|
|
|
3
4
|
from multi.errors import NoRepositoriesError
|
|
@@ -31,7 +32,28 @@ class Repository:
|
|
|
31
32
|
else:
|
|
32
33
|
raise ValueError("Repository must have either 'url' or 'name'")
|
|
33
34
|
self.paths = paths
|
|
34
|
-
|
|
35
|
+
|
|
36
|
+
# Custom on-disk location, relative to the workspace root. Defaults to
|
|
37
|
+
# the repo name (a flat layout). Allows repos to live under nested
|
|
38
|
+
# directories, e.g. "path": "services/api".
|
|
39
|
+
relative_path = kwargs.pop("path", None)
|
|
40
|
+
if relative_path is None:
|
|
41
|
+
self.relative_path = self.name
|
|
42
|
+
else:
|
|
43
|
+
if not isinstance(relative_path, str):
|
|
44
|
+
raise ValueError(f"path for repository {self.name} must be a string.")
|
|
45
|
+
raw = relative_path.strip()
|
|
46
|
+
posix = PurePosixPath(raw)
|
|
47
|
+
# Reject absolute paths and parent-directory escapes before
|
|
48
|
+
# normalizing, so a leading '/' is an error rather than silently
|
|
49
|
+
# stripped.
|
|
50
|
+
if not raw or posix.is_absolute() or ".." in posix.parts:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"path for repository {self.name} must be a relative path "
|
|
53
|
+
"inside the workspace (no leading '/' and no '..')."
|
|
54
|
+
)
|
|
55
|
+
self.relative_path = posix.as_posix()
|
|
56
|
+
self.path = self.paths.root_dir / Path(self.relative_path)
|
|
35
57
|
|
|
36
58
|
# Set 'skip' attribute, defaulting to False if not provided in kwargs
|
|
37
59
|
self.skip_vscode = kwargs.pop("skipVSCode", False)
|
|
@@ -44,6 +66,10 @@ class Repository:
|
|
|
44
66
|
|
|
45
67
|
# Keep this repo on a fixed branch during branch synchronization.
|
|
46
68
|
self.fixed_branch = kwargs.pop("fixedBranch", None)
|
|
69
|
+
if self.fixed_branch is not None and not isinstance(self.fixed_branch, str):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"fixedBranch for repository {self.name} must be a string."
|
|
72
|
+
)
|
|
47
73
|
|
|
48
74
|
install_sets = kwargs.pop("installSets", None)
|
|
49
75
|
if install_sets is not None:
|
|
@@ -11,7 +11,7 @@ from git.exc import GitCommandError
|
|
|
11
11
|
from multi.bootstrap import ensure_root_git_repo, ensure_workspace_readme
|
|
12
12
|
from multi.cli_helpers import common_command_wrapper
|
|
13
13
|
from multi.doctor import find_nested_git_repos_in_monorepo
|
|
14
|
-
from multi.git_helpers import get_current_branch
|
|
14
|
+
from multi.git_helpers import expected_branch_for_repo, get_current_branch
|
|
15
15
|
from multi.ignore_files import (
|
|
16
16
|
update_gitignore_with_generated_files,
|
|
17
17
|
update_gitignore_with_repos,
|
|
@@ -39,7 +39,7 @@ def _is_path_inside(child: Path, parent: Path) -> bool:
|
|
|
39
39
|
def _try_symlink_repo(
|
|
40
40
|
repo_config: Repository,
|
|
41
41
|
paths: Paths,
|
|
42
|
-
|
|
42
|
+
expected_branch: str | None,
|
|
43
43
|
) -> bool:
|
|
44
44
|
"""Attempt to create a symlink for a repository.
|
|
45
45
|
|
|
@@ -75,18 +75,20 @@ def _try_symlink_repo(
|
|
|
75
75
|
|
|
76
76
|
# Try to create the symlink
|
|
77
77
|
try:
|
|
78
|
+
# Create any parent directories for nested custom paths.
|
|
79
|
+
os.makedirs(repo_config.path.parent, exist_ok=True)
|
|
78
80
|
os.symlink(existing_path, repo_config.path)
|
|
79
81
|
logger.info(f"🔗 Symlinked {repo_config.name} -> {existing_path}")
|
|
80
82
|
|
|
81
|
-
# Checkout the
|
|
82
|
-
if
|
|
83
|
+
# Checkout the expected branch if one is defined for this repo.
|
|
84
|
+
if expected_branch:
|
|
83
85
|
try:
|
|
84
86
|
symlinked_repo = git.Repo(repo_config.path)
|
|
85
|
-
symlinked_repo.git.checkout(
|
|
86
|
-
logger.debug(f"Checked out branch {
|
|
87
|
+
symlinked_repo.git.checkout(expected_branch)
|
|
88
|
+
logger.debug(f"Checked out branch {expected_branch} in symlinked repo")
|
|
87
89
|
except GitCommandError:
|
|
88
90
|
logger.warning(
|
|
89
|
-
f"Branch {
|
|
91
|
+
f"Branch {expected_branch} not found in {repo_config.name}, staying on current branch."
|
|
90
92
|
)
|
|
91
93
|
|
|
92
94
|
return True
|
|
@@ -97,7 +99,7 @@ def _try_symlink_repo(
|
|
|
97
99
|
|
|
98
100
|
def _clone_repo(
|
|
99
101
|
repo_config: Repository,
|
|
100
|
-
|
|
102
|
+
expected_branch: str | None,
|
|
101
103
|
) -> None:
|
|
102
104
|
"""Clone a repository and checkout the appropriate branch."""
|
|
103
105
|
logger.debug(f"Cloning {repo_config.name}...")
|
|
@@ -112,18 +114,21 @@ def _clone_repo(
|
|
|
112
114
|
# workspace root gitignore already manages sub-repo paths.
|
|
113
115
|
cloned_repo = git.Repo.clone_from(repo_config.url, temp_repo_path)
|
|
114
116
|
|
|
115
|
-
# Then checkout the
|
|
116
|
-
if
|
|
117
|
+
# Then checkout the expected branch if one is defined for this repo.
|
|
118
|
+
if expected_branch:
|
|
117
119
|
try:
|
|
118
|
-
cloned_repo.git.checkout(
|
|
120
|
+
cloned_repo.git.checkout(expected_branch)
|
|
119
121
|
logger.info(
|
|
120
|
-
f"✅ Cloned {repo_config.name} and checked out branch {
|
|
122
|
+
f"✅ Cloned {repo_config.name} and checked out branch {expected_branch}"
|
|
121
123
|
)
|
|
122
124
|
except GitCommandError:
|
|
123
125
|
logger.warning(
|
|
124
|
-
f"Branch {
|
|
126
|
+
f"Branch {expected_branch} not found in {repo_config.name}, staying on default branch."
|
|
125
127
|
)
|
|
126
128
|
|
|
129
|
+
# Create any parent directories for nested custom paths before moving
|
|
130
|
+
# the checked-out repo into place.
|
|
131
|
+
os.makedirs(repo_config.path.parent, exist_ok=True)
|
|
127
132
|
shutil.move(str(temp_repo_path), str(repo_config.path))
|
|
128
133
|
|
|
129
134
|
|
|
@@ -145,6 +150,7 @@ def clone_repos(paths: Paths, ensure_on_same_branch: bool = True):
|
|
|
145
150
|
logger.info(f"Current branch: {current_branch}")
|
|
146
151
|
|
|
147
152
|
for repo_config in repos:
|
|
153
|
+
expected_branch = expected_branch_for_repo(repo_config, current_branch)
|
|
148
154
|
if repo_config.path.exists():
|
|
149
155
|
# Path already exists - register it and skip
|
|
150
156
|
logger.debug(
|
|
@@ -154,11 +160,11 @@ def clone_repos(paths: Paths, ensure_on_same_branch: bool = True):
|
|
|
154
160
|
continue
|
|
155
161
|
|
|
156
162
|
# Try to symlink first
|
|
157
|
-
if _try_symlink_repo(repo_config, paths,
|
|
163
|
+
if _try_symlink_repo(repo_config, paths, expected_branch):
|
|
158
164
|
continue
|
|
159
165
|
|
|
160
166
|
# Fall back to cloning
|
|
161
|
-
_clone_repo(repo_config,
|
|
167
|
+
_clone_repo(repo_config, expected_branch)
|
|
162
168
|
|
|
163
169
|
# Register the newly cloned repo
|
|
164
170
|
register_repo(repo_config.url, repo_config.path)
|
|
@@ -44,7 +44,7 @@ def sync_devcontainer(root_dir: Path, install_set: str | None = None) -> None:
|
|
|
44
44
|
if devcontainer_json:
|
|
45
45
|
devcontainer_json = prefix_repo_name_to_path_recursive(
|
|
46
46
|
devcontainer_json,
|
|
47
|
-
repo.
|
|
47
|
+
repo.relative_path,
|
|
48
48
|
resolve_relative_paths=True,
|
|
49
49
|
)
|
|
50
50
|
write_json_file(devcontainer_json_path, devcontainer_json)
|
|
@@ -201,7 +201,9 @@ class VSCodeFileMerger(ABC):
|
|
|
201
201
|
effective_repo_json, repo, source_file_display
|
|
202
202
|
)
|
|
203
203
|
|
|
204
|
-
return deep_merge(
|
|
204
|
+
return deep_merge(
|
|
205
|
+
merged_json, effective_repo_json, repo.relative_path, skip_keys
|
|
206
|
+
)
|
|
205
207
|
|
|
206
208
|
def _post_process_json(self, merged_json: Dict[str, Any]) -> Dict[str, Any]:
|
|
207
209
|
"""
|
|
@@ -61,7 +61,7 @@ class LaunchFileMerger(VSCodeFileMerger):
|
|
|
61
61
|
if isinstance(config, dict):
|
|
62
62
|
if "cwd" not in config:
|
|
63
63
|
config["cwd"] = prefix_repo_name_to_path(
|
|
64
|
-
"${workspaceFolder}", repo.
|
|
64
|
+
"${workspaceFolder}", repo.relative_path
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
# Check if repo has requiredCompounds config and mark matching compounds
|
|
@@ -13,9 +13,7 @@ from multi.utils import (
|
|
|
13
13
|
write_jsonc_file,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
LOCAL_GENERATED_SETTINGS_HEADER =
|
|
17
|
-
"This file is generated by multi from settings.shared.json and/or settings.local.json. Do not edit directly."
|
|
18
|
-
)
|
|
16
|
+
LOCAL_GENERATED_SETTINGS_HEADER = "This file is generated by multi from settings.shared.json and/or settings.local.json. Do not edit directly."
|
|
19
17
|
|
|
20
18
|
logger = logging.getLogger(__name__)
|
|
21
19
|
|
|
@@ -124,7 +122,7 @@ class SettingsFileMerger(VSCodeFileMerger):
|
|
|
124
122
|
|
|
125
123
|
# Add Python paths for autocomplete
|
|
126
124
|
repos = load_repos(self.paths)
|
|
127
|
-
python_paths_to_add = [repo.
|
|
125
|
+
python_paths_to_add = [repo.relative_path for repo in repos if repo.is_python]
|
|
128
126
|
if python_paths_to_add:
|
|
129
127
|
logger.info("Adding Python paths for autocomplete")
|
|
130
128
|
current_extra_paths = merged_json.setdefault(
|
|
@@ -13,6 +13,8 @@ from multi.git_helpers import (
|
|
|
13
13
|
check_all_on_same_branch,
|
|
14
14
|
check_all_repos_are_clean,
|
|
15
15
|
check_branch_existence,
|
|
16
|
+
expected_branch_for_repo,
|
|
17
|
+
repo_uses_fixed_branch,
|
|
16
18
|
)
|
|
17
19
|
from multi.git_set_branch import create_and_switch_branch
|
|
18
20
|
from multi.paths import Paths
|
|
@@ -177,8 +179,9 @@ def _checkout_subrepos(
|
|
|
177
179
|
) -> None:
|
|
178
180
|
source_by_name = {repo.name: repo for repo in source_repos}
|
|
179
181
|
for repo in destination_repos:
|
|
180
|
-
repo_branch_name = repo
|
|
181
|
-
|
|
182
|
+
repo_branch_name = expected_branch_for_repo(repo, branch_name)
|
|
183
|
+
assert repo_branch_name is not None
|
|
184
|
+
if repo_uses_fixed_branch(repo):
|
|
182
185
|
create_and_switch_branch(
|
|
183
186
|
repo.path,
|
|
184
187
|
repo_branch_name,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|