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.
Files changed (47) hide show
  1. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/PKG-INFO +2 -2
  2. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/_version.py +2 -2
  3. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/branch.py +30 -14
  4. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/cli_helpers.py +1 -1
  5. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/git_helpers.py +28 -6
  6. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/git_run.py +3 -3
  7. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/git_set_branch.py +4 -2
  8. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/ignore_files.py +13 -5
  9. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/remove.py +8 -3
  10. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/repos.py +27 -1
  11. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync.py +21 -15
  12. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_devcontainer.py +1 -1
  13. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_helpers.py +3 -1
  14. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_launch.py +1 -1
  15. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_settings.py +2 -4
  16. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/worktree.py +5 -2
  17. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/.gitignore +0 -0
  18. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/LICENSE +0 -0
  19. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/README.md +0 -0
  20. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/__init__.py +0 -0
  21. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/__main__.py +0 -0
  22. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/add.py +0 -0
  23. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/api.py +0 -0
  24. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/app_api.py +0 -0
  25. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/bootstrap.py +0 -0
  26. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/cli.py +0 -0
  27. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/collaborator.py +0 -0
  28. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/convert_monorepo.py +0 -0
  29. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/doctor.py +0 -0
  30. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/errors.py +0 -0
  31. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/init.py +0 -0
  32. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/logging.py +0 -0
  33. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/managed_blocks.py +0 -0
  34. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/open.py +0 -0
  35. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/paths.py +0 -0
  36. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/registry.py +0 -0
  37. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/repo_urls.py +0 -0
  38. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/resources/init_readme.md +0 -0
  39. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/service.py +0 -0
  40. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/settings.py +0 -0
  41. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_agents.py +0 -0
  42. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_github.py +0 -0
  43. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode.py +0 -0
  44. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_extensions.py +0 -0
  45. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/sync_vscode_tasks.py +0 -0
  46. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/multi/utils.py +0 -0
  47. {multi_workspace-3.2.12 → multi_workspace-3.2.13}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: multi-workspace
3
- Version: 3.2.12
3
+ Version: 3.2.13
4
4
  Summary: Multi
5
5
  Project-URL: Repository, https://github.com/gabemontague/multi
6
6
  Project-URL: Issues, https://github.com/gabemontague/multi/issues
@@ -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.12'
22
- __version_tuple__ = version_tuple = (3, 2, 12)
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 describe_head
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.fixed_branch or root_branch
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
- if repo.fixed_branch:
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
- @click.command(name="branch")
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 the same branch as the root repo.
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.fixed_branch or root_branch
98
+ expected_branch = expected_branch_for_repo(repo, root_branch)
74
99
  if branch != expected_branch:
75
100
  if raise_error:
76
- if repo.fixed_branch:
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 the same branch
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 the same branch (working trees may be
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 the same branch as the root repo. If the branch already exists for all repos, this command will fix the situation."
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.fixed_branch or branch_name
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 has both forms: trailing slash matches directories, bare name matches symlinks/aliases.",
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.name}/")
62
- entries.append(repo.name)
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"!{repo.name}/")
72
- entries.append(f"!{repo.name}")
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 / repo_name
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, [repo_name])
85
- remove_ignore_entries_for_repos(paths, [repo_name])
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
- self.path = self.paths.root_dir / self.name
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
- current_branch: str | None,
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 correct branch if needed
82
- if current_branch:
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(current_branch)
86
- logger.debug(f"Checked out branch {current_branch} in symlinked repo")
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 {current_branch} not found in {repo_config.name}, staying on current 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
- current_branch: str | None,
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 same branch as parent repo if it exists
116
- if current_branch:
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(current_branch)
120
+ cloned_repo.git.checkout(expected_branch)
119
121
  logger.info(
120
- f"✅ Cloned {repo_config.name} and checked out branch {current_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 {current_branch} not found in {repo_config.name}, staying on default 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, current_branch):
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, current_branch)
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.name,
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(merged_json, effective_repo_json, repo.name, skip_keys)
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.name
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.name for repo in repos if repo.is_python]
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.fixed_branch or branch_name
181
- if repo.fixed_branch:
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,