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 +1 -0
- multi/__main__.py +4 -0
- multi/_version.py +34 -0
- multi/cli.py +45 -0
- multi/cli_helpers.py +79 -0
- multi/errors.py +22 -0
- multi/git_helpers.py +109 -0
- multi/git_run.py +59 -0
- multi/git_set_branch.py +66 -0
- multi/ignore_files.py +104 -0
- multi/init.py +193 -0
- multi/logging.py +32 -0
- multi/paths.py +85 -0
- multi/repos.py +92 -0
- multi/resources/init_readme.md +11 -0
- multi/rules.py +100 -0
- multi/settings.py +35 -0
- multi/sync.py +89 -0
- multi/sync_claude.py +88 -0
- multi/sync_ruff.py +85 -0
- multi/sync_vscode.py +51 -0
- multi/sync_vscode_extensions.py +52 -0
- multi/sync_vscode_helpers.py +169 -0
- multi/sync_vscode_launch.py +105 -0
- multi/sync_vscode_settings.py +100 -0
- multi/sync_vscode_tasks.py +93 -0
- multi/utils.py +163 -0
- multi_workspace-3.0.0.dist-info/METADATA +57 -0
- multi_workspace-3.0.0.dist-info/RECORD +32 -0
- multi_workspace-3.0.0.dist-info/WHEEL +4 -0
- multi_workspace-3.0.0.dist-info/entry_points.txt +2 -0
- multi_workspace-3.0.0.dist-info/licenses/LICENSE +9 -0
multi/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
multi/__main__.py
ADDED
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))
|
multi/git_set_branch.py
ADDED
|
@@ -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")
|