git-worm 0.1.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.
git_worm/__init__.py ADDED
File without changes
git_worm/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ from xclif import Cli
2
+
3
+ from . import routes
4
+
5
+ cli = Cli.from_routes(routes, local_config=".git-worm.toml")
6
+
7
+ if __name__ == "__main__":
8
+ cli()
git_worm/config.py ADDED
@@ -0,0 +1,35 @@
1
+ """Parse .git-worm.toml configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ShareRule:
12
+ path: str
13
+ strategy: str # "copy", "reflink", "symlink", "ignore"
14
+
15
+
16
+ @dataclass
17
+ class Config:
18
+ worktree_dir: str = ".worktrees"
19
+ share_rules: list[ShareRule] = field(default_factory=list)
20
+
21
+
22
+ def load_config(path: Path) -> Config | None:
23
+ """Load config from a TOML file. Returns None if file doesn't exist."""
24
+ if not path.exists():
25
+ return None
26
+ with open(path, "rb") as f:
27
+ data = tomllib.load(f)
28
+ share_rules = [
29
+ ShareRule(path=rule["path"], strategy=rule["strategy"])
30
+ for rule in data.get("share", [])
31
+ ]
32
+ return Config(
33
+ worktree_dir=data.get("worktree_dir", ".worktrees"),
34
+ share_rules=share_rules,
35
+ )
git_worm/files.py ADDED
@@ -0,0 +1,246 @@
1
+ """Gitignored file detection and copy/reflink/symlink logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import shutil
7
+ import subprocess
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from fnmatch import fnmatch
10
+ from pathlib import Path
11
+
12
+ from git_worm.config import ShareRule
13
+
14
+ EXCLUDED_NAMES = {".git", ".worktrees"}
15
+ _IS_MACOS = platform.system() == "Darwin"
16
+
17
+
18
+ def _reflink_cmd(src: Path, dst: Path, *, recursive: bool = False) -> list[str]:
19
+ """Build a cp command that attempts copy-on-write cloning."""
20
+ if _IS_MACOS:
21
+ # macOS APFS: cp -c (clone)
22
+ flags = "-ac" if recursive else "-c"
23
+ else:
24
+ # Linux: cp --reflink=auto
25
+ flags = "-a --reflink=auto" if recursive else "--reflink=auto"
26
+ return ["cp", *flags.split(), str(src), str(dst)]
27
+
28
+
29
+ def _has_tracked_files(repo: Path, directory: str) -> bool:
30
+ """Check if a directory contains any files tracked by git."""
31
+ result = subprocess.run(
32
+ ["git", "ls-files", directory],
33
+ cwd=repo,
34
+ check=True,
35
+ capture_output=True,
36
+ text=True,
37
+ )
38
+ return bool(result.stdout.strip())
39
+
40
+
41
+ def get_ignored_entries(repo: Path) -> list[Path]:
42
+ """Get gitignored files and directories in the repo.
43
+
44
+ Returns top-level entries when the entire directory is ignored.
45
+ For tracked directories that contain ignored files, returns the
46
+ individual ignored file paths instead.
47
+ """
48
+ result = subprocess.run(
49
+ ["git", "status", "--ignored", "--porcelain"],
50
+ cwd=repo,
51
+ check=True,
52
+ capture_output=True,
53
+ text=True,
54
+ )
55
+ entries = []
56
+ seen: set[str] = set()
57
+ for line in result.stdout.splitlines():
58
+ if not line.startswith("!! "):
59
+ continue
60
+ rel = line.removeprefix("!! ").rstrip("/")
61
+ top_level = rel.split("/")[0]
62
+ if top_level in EXCLUDED_NAMES:
63
+ continue
64
+ path = repo / top_level
65
+ if not path.exists():
66
+ continue
67
+ if top_level in seen:
68
+ continue
69
+ # If this is a directory that also has tracked files, we can't
70
+ # copy the whole thing — get the individual ignored files instead.
71
+ if path.is_dir() and _has_tracked_files(repo, top_level):
72
+ seen.add(top_level)
73
+ nested = _get_ignored_files_in(repo, top_level)
74
+ entries.extend(nested)
75
+ else:
76
+ seen.add(top_level)
77
+ entries.append(path)
78
+ return entries
79
+
80
+
81
+ def _get_ignored_files_in(repo: Path, directory: str) -> list[Path]:
82
+ """Get individual ignored file paths within a partially-tracked directory."""
83
+ result = subprocess.run(
84
+ ["git", "status", "--ignored", "--porcelain", directory],
85
+ cwd=repo,
86
+ check=True,
87
+ capture_output=True,
88
+ text=True,
89
+ )
90
+ paths = []
91
+ for line in result.stdout.splitlines():
92
+ if not line.startswith("!! "):
93
+ continue
94
+ rel = line.removeprefix("!! ").rstrip("/")
95
+ path = repo / rel
96
+ if path.exists():
97
+ paths.append(path)
98
+ return paths
99
+
100
+
101
+ # Directories where copying is useless because the contents are symlinks
102
+ # or other indirection that won't survive a copy. The toolchain must be
103
+ # re-run in the worktree to regenerate them correctly.
104
+ #
105
+ # Each entry maps a directory name to a list of marker groups.
106
+ # A marker group is a tuple of filenames that must ALL exist for the match.
107
+ # If ANY group matches, the directory should be skipped.
108
+ _UNCOPYABLE_DIRS: dict[str, list[tuple[str, ...]]] = {
109
+ # JS/TS — pnpm/bun use hardlinks into a global store, Yarn PnP
110
+ # replaces node_modules entirely, Deno uses URL imports.
111
+ # Plain npm/yarn classic have real files worth copying.
112
+ "node_modules": [
113
+ ("pnpm-lock.yaml",),
114
+ ("bun.lockb",),
115
+ ("bun.lock",),
116
+ ("yarn.lock", ".pnp.cjs"),
117
+ ("yarn.lock", ".pnp.mjs"),
118
+ ("deno.lock",),
119
+ ],
120
+ }
121
+
122
+
123
+ def _is_uncopyable(entry_name: str, repo: Path) -> bool:
124
+ """Check if a directory uses indirection that won't survive copying."""
125
+ groups = _UNCOPYABLE_DIRS.get(entry_name)
126
+ if groups is None:
127
+ return False
128
+ return any(
129
+ all((repo / m).exists() for m in group)
130
+ for group in groups
131
+ )
132
+
133
+
134
+ def should_skip_node_modules(repo: Path) -> bool:
135
+ """Detect if the package manager handles node_modules efficiently."""
136
+ return _is_uncopyable("node_modules", repo)
137
+
138
+
139
+ def copy_entry(
140
+ entry: Path,
141
+ src_root: Path,
142
+ dst_root: Path,
143
+ *,
144
+ strategy: str,
145
+ ) -> dict[str, str]:
146
+ """Copy a single file or directory using the given strategy.
147
+
148
+ Returns a dict with 'name' and 'action' keys describing what happened.
149
+ """
150
+ rel = entry.relative_to(src_root)
151
+ dst = dst_root / rel
152
+ name = str(rel)
153
+
154
+ if strategy == "ignore":
155
+ return {"name": name, "action": "ignored"}
156
+
157
+ if strategy == "symlink":
158
+ dst.symlink_to(entry.resolve())
159
+ return {"name": name, "action": "symlinked"}
160
+
161
+ if strategy == "copy":
162
+ if entry.is_dir():
163
+ shutil.copytree(entry, dst)
164
+ else:
165
+ dst.parent.mkdir(parents=True, exist_ok=True)
166
+ shutil.copy2(entry, dst)
167
+ return {"name": name, "action": "copied"}
168
+
169
+ if strategy == "reflink":
170
+ if entry.is_dir():
171
+ try:
172
+ subprocess.run(
173
+ _reflink_cmd(entry, dst, recursive=True),
174
+ check=True,
175
+ capture_output=True,
176
+ )
177
+ return {"name": name, "action": "COW"}
178
+ except (subprocess.CalledProcessError, FileNotFoundError):
179
+ shutil.copytree(entry, dst)
180
+ return {"name": name, "action": "copied (COW unavailable)"}
181
+ else:
182
+ dst.parent.mkdir(parents=True, exist_ok=True)
183
+ try:
184
+ subprocess.run(
185
+ _reflink_cmd(entry, dst),
186
+ check=True,
187
+ capture_output=True,
188
+ )
189
+ return {"name": name, "action": "COW"}
190
+ except (subprocess.CalledProcessError, FileNotFoundError):
191
+ shutil.copy2(entry, dst)
192
+ return {"name": name, "action": "copied (COW unavailable)"}
193
+
194
+ return {"name": name, "action": f"unknown strategy: {strategy}"}
195
+
196
+
197
+ def _default_strategy(entry: Path, repo: Path) -> str:
198
+ """Determine the default strategy for an ignored entry."""
199
+ if entry.is_dir() and _is_uncopyable(entry.name, repo):
200
+ return "ignore"
201
+ if entry.is_dir():
202
+ return "reflink"
203
+ return "copy"
204
+
205
+
206
+ def _match_rule(entry_name: str, rules: list[ShareRule]) -> ShareRule | None:
207
+ """Find the first matching share rule for an entry name."""
208
+ for rule in rules:
209
+ if fnmatch(entry_name, rule.path):
210
+ return rule
211
+ return None
212
+
213
+
214
+ def copy_ignored_files(
215
+ src: Path,
216
+ dst: Path,
217
+ *,
218
+ share_rules: list[ShareRule] | None = None,
219
+ ) -> list[dict[str, str]]:
220
+ """Copy all gitignored files from src to dst.
221
+
222
+ If share_rules is provided, it replaces default behavior entirely.
223
+ """
224
+ entries = get_ignored_entries(src)
225
+
226
+ # Resolve strategies and filter entries
227
+ work: list[tuple[Path, str]] = []
228
+ for entry in entries:
229
+ name = entry.name
230
+ if share_rules is not None:
231
+ rule = _match_rule(name, share_rules)
232
+ if rule is None:
233
+ continue
234
+ strategy = rule.strategy
235
+ else:
236
+ strategy = _default_strategy(entry, src)
237
+ work.append((entry, strategy))
238
+
239
+ if len(work) <= 3:
240
+ return [copy_entry(e, src, dst, strategy=s) for e, s in work]
241
+
242
+ with ThreadPoolExecutor() as pool:
243
+ futures = [
244
+ pool.submit(copy_entry, e, src, dst, strategy=s) for e, s in work
245
+ ]
246
+ return [f.result() for f in futures]
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("git-worm")
5
+ def _() -> None:
6
+ """A better git worktree manager."""
@@ -0,0 +1,44 @@
1
+ import rich
2
+ from rich.tree import Tree
3
+
4
+ from git_worm.worktree import list_worktrees, is_dirty, is_merged
5
+ from xclif import command
6
+
7
+ from pathlib import Path
8
+
9
+
10
+ @command("list", "ls")
11
+ def _() -> None:
12
+ """List all worktrees."""
13
+ worktrees = list_worktrees()
14
+
15
+ if not worktrees:
16
+ rich.print("[dim]No worktrees found.[/dim]")
17
+ return
18
+
19
+ tree = Tree("[bold]Worktrees[/bold]")
20
+ for i, wt in enumerate(worktrees):
21
+ path = wt["path"]
22
+ branch = wt.get("branch", wt.get("head", "???")[:8])
23
+ is_bare = wt.get("bare") == "true"
24
+ is_detached = wt.get("detached") == "true"
25
+ is_primary = i == 0
26
+
27
+ if is_bare:
28
+ label = f"[dim]{path}[/dim] [italic](bare)[/italic]"
29
+ elif is_detached:
30
+ label = f"[bold]{branch}[/bold] [dim]{path}[/dim] [yellow](detached)[/yellow]"
31
+ else:
32
+ dirty = is_dirty(Path(path))
33
+ merged = is_merged(branch)
34
+ status = ""
35
+ if dirty:
36
+ status += " [red][italic]dirty[/italic][/red]"
37
+ if merged:
38
+ status += " [green](merged)[/green]"
39
+ branch_fmt = f"[bold blue]{branch}[/bold blue]" if is_primary else f"[bold]{branch}[/bold]"
40
+ label = f"{branch_fmt} [dim]{path}[/dim]{status}"
41
+
42
+ tree.add(label)
43
+
44
+ rich.print(tree)
git_worm/routes/new.py ADDED
@@ -0,0 +1,72 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from git_worm.config import load_config
6
+ from git_worm.files import copy_ignored_files
7
+ from git_worm.worktree import add_worktree, find_repo_root
8
+ from xclif import Arg, Option, WithConfig, command
9
+ from xclif.context import get_context
10
+
11
+
12
+ @command("new", "add")
13
+ def _(
14
+ branch: Annotated[str, Arg(description="Branch name to check out (or create with --from)")],
15
+ from_ref: Annotated[str, Option(name="from", description="Create branch from this ref")] = "",
16
+ worktree_dir: WithConfig[str] = ".worktrees",
17
+ *branches: str,
18
+ ) -> None:
19
+ """Create one or more new worktrees for branches."""
20
+ all_branches = (branch, *branches)
21
+ repo = find_repo_root()
22
+ config = load_config(repo / ".git-worm.toml")
23
+
24
+ failed = False
25
+ for b in all_branches:
26
+ wt_path = repo / worktree_dir / b
27
+
28
+ if wt_path.exists():
29
+ rich.print(f"[bold red]Error:[/bold red] Worktree already exists at [bold]{wt_path}[/bold]")
30
+ failed = True
31
+ continue
32
+
33
+ # Create the worktree
34
+ add_worktree(wt_path, b, from_ref=from_ref or None)
35
+
36
+ # Ensure .worktrees/.gitignore exists
37
+ wt_dir = repo / worktree_dir
38
+ gitignore = wt_dir / ".gitignore"
39
+ if not gitignore.exists():
40
+ gitignore.write_text("*\n")
41
+
42
+ # Copy gitignored files
43
+ share_rules = config.share_rules if config else None
44
+ results = copy_ignored_files(repo, wt_path, share_rules=share_rules)
45
+
46
+ # Print summary
47
+ copied = [r for r in results if r["action"] != "ignored"]
48
+ rich.print(f"\n[bold green]Created worktree[/bold green] [bold]{b}[/bold] @ [dim]{wt_path}[/dim]")
49
+ if results and get_context().verbosity >= 1:
50
+ if copied:
51
+ rich.print(f"[dim]Copied {len(copied)} ignored file(s)[/dim]")
52
+ for r in results:
53
+ action = r["action"]
54
+ name = r["name"]
55
+ icon = {"copied": "+", "COW": "~", "symlinked": "->", "ignored": "x"}.get(
56
+ action, "+"
57
+ )
58
+ if action == "ignored":
59
+ rich.print(f" [dim]{icon} {name} ({action})[/dim]")
60
+ elif action == "symlinked":
61
+ rich.print(f" [cyan]{icon}[/cyan] {name} [dim]({action})[/dim]")
62
+ elif action == "COW":
63
+ rich.print(f" [magenta]{icon}[/magenta] {name} [dim]({action})[/dim]")
64
+ else:
65
+ rich.print(f" [green]{icon}[/green] {name} [dim]({action})[/dim]")
66
+
67
+ if len(all_branches) == 1 and not failed:
68
+ wt_path = repo / worktree_dir / all_branches[0]
69
+ rich.print(f"\n[dim]Go to your new worktree with[/dim] [bold]cd {wt_path}[/bold]")
70
+
71
+ if failed:
72
+ return 1
@@ -0,0 +1,72 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ import rich
6
+
7
+ from git_worm.worktree import find_repo_root, list_worktrees, remove_worktree
8
+ from xclif import Option, command
9
+
10
+
11
+ @command()
12
+ def _(
13
+ merged: Annotated[bool, Option(description="Also remove worktrees whose branches are fully merged into main")] = False,
14
+ ) -> None:
15
+ """Remove stale worktree administrative files.
16
+
17
+ Runs `git worktree prune` to clean up refs for worktrees that have
18
+ been deleted manually without using `git worm rm`.
19
+ """
20
+ result = subprocess.run(
21
+ ["git", "worktree", "prune", "--verbose"],
22
+ check=True,
23
+ capture_output=True,
24
+ text=True,
25
+ )
26
+ if result.stderr.strip():
27
+ for line in result.stderr.strip().splitlines():
28
+ rich.print(f"[dim]pruned:[/dim] {line}")
29
+ else:
30
+ rich.print("[dim]Nothing to prune.[/dim]")
31
+
32
+ if merged:
33
+ _prune_merged()
34
+
35
+
36
+ def _prune_merged() -> None:
37
+ """Remove worktrees whose branches are fully merged into the main branch."""
38
+ repo = find_repo_root()
39
+ worktrees = list_worktrees()
40
+
41
+ # Find the main branch name (first worktree is the primary one)
42
+ if not worktrees:
43
+ return
44
+ main_branch = worktrees[0].get("branch")
45
+ if not main_branch:
46
+ return
47
+
48
+ # Get branches merged into the main branch
49
+ result = subprocess.run(
50
+ ["git", "branch", "--merged", main_branch],
51
+ check=True,
52
+ capture_output=True,
53
+ text=True,
54
+ )
55
+ merged_branches = {
56
+ line.strip().lstrip("*+ ")
57
+ for line in result.stdout.splitlines()
58
+ if not line.startswith("*")
59
+ }
60
+
61
+ pruned = False
62
+ for wt in worktrees[1:]: # skip the primary worktree
63
+ branch = wt.get("branch")
64
+ if not branch or branch not in merged_branches:
65
+ continue
66
+ path = Path(wt["path"])
67
+ remove_worktree(path)
68
+ rich.print(f"[bold green]Removed merged worktree[/bold green] [bold]{branch}[/bold]")
69
+ pruned = True
70
+
71
+ if not pruned:
72
+ rich.print("[dim]No merged worktrees to remove.[/dim]")
@@ -0,0 +1,41 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from git_worm.worktree import find_repo_root, is_dirty, remove_worktree
6
+ from xclif import Arg, Option, WithConfig, command
7
+
8
+
9
+ @command("remove", "rm")
10
+ def _(
11
+ branch: Annotated[str, Arg(description="Name of the worktree to remove")],
12
+ force: Annotated[bool, Option(description="Remove even if the worktree has uncommitted changes")] = False,
13
+ worktree_dir: WithConfig[str] = ".worktrees",
14
+ *branches: str,
15
+ ) -> None:
16
+ """Remove one or more worktrees."""
17
+ branches = (branch, *branches)
18
+ repo = find_repo_root()
19
+
20
+ failed = False
21
+ for b in branches:
22
+ wt_path = repo / worktree_dir / b
23
+
24
+ if not wt_path.exists():
25
+ rich.print(f"[bold red]Error:[/bold red] No worktree found at [bold]{wt_path}[/bold]")
26
+ failed = True
27
+ continue
28
+
29
+ if not force and is_dirty(wt_path):
30
+ rich.print(
31
+ f"[bold yellow]Warning:[/bold yellow] Worktree [bold]{b}[/bold] has uncommitted changes.\n"
32
+ f"Use [bold]--force[/bold] to remove anyway."
33
+ )
34
+ failed = True
35
+ continue
36
+
37
+ remove_worktree(wt_path, force=force)
38
+ rich.print(f"[bold green]Removed worktree[/bold green] [bold]{b}[/bold]")
39
+
40
+ if failed:
41
+ return 1
@@ -0,0 +1,37 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("shell-init")
5
+ def _() -> None:
6
+ """Output a `worm` shell function that wraps git-worm with cd integration.
7
+
8
+ The `worm switch` command will cd into the worktree directory,
9
+ which is not possible with a plain subprocess. All other commands
10
+ are forwarded to git-worm as-is.
11
+
12
+ Add `eval "$(git-worm shell-init)"` to your shell rc file.
13
+ """
14
+ print("""\
15
+ worm() {
16
+ if [ "$1" = "switch" ] && [ -n "$2" ]; then
17
+ local wt_dir
18
+ wt_dir="$(git rev-parse --show-toplevel)/.worktrees/$2"
19
+ if [ -d "$wt_dir" ]; then
20
+ cd "$wt_dir"
21
+ echo "Switched to worktree '$2' @ $wt_dir"
22
+ else
23
+ echo "Error: No worktree found for '$2'" >&2
24
+ return 1
25
+ fi
26
+ elif [ "$1" = "new" ]; then
27
+ local _worm_out _worm_cd
28
+ _worm_out="$(git-worm "$@")" || { echo "$_worm_out"; return 1; }
29
+ _worm_cd="$(echo "$_worm_out" | grep 'Go to your new worktree with cd ' | cut -d: -f2-)"
30
+ echo "$_worm_out" | grep -v 'Go to your new worktree with cd '
31
+ if [ -n "$_worm_cd" ] && [ -d "$_worm_cd" ]; then
32
+ cd "$_worm_cd"
33
+ fi
34
+ else
35
+ git-worm "$@"
36
+ fi
37
+ }""")
@@ -0,0 +1,22 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from git_worm.worktree import find_repo_root
6
+ from xclif import Arg, WithConfig, command
7
+
8
+
9
+ @command()
10
+ def _(
11
+ branch: Annotated[str, Arg(description="Name of the worktree to switch to")],
12
+ worktree_dir: WithConfig[str] = ".worktrees",
13
+ ) -> None:
14
+ """Print the path to a worktree."""
15
+ repo = find_repo_root()
16
+ wt_path = repo / worktree_dir / branch
17
+
18
+ if not wt_path.exists():
19
+ rich.print(f"[bold red]Error:[/bold red] No worktree found for [bold]{branch}[/bold]")
20
+ return 1
21
+
22
+ rich.print(f"Worktree for [bold]{branch}[/bold] @ [bold]{wt_path}[/bold]")
git_worm/worktree.py ADDED
@@ -0,0 +1,101 @@
1
+ """Thin wrapper around git worktree commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ def add_worktree(path: Path, branch: str, *, from_ref: str | None = None) -> None:
10
+ """Create a new worktree. Creates a new branch if from_ref is given."""
11
+ path.parent.mkdir(parents=True, exist_ok=True)
12
+ if from_ref is not None:
13
+ subprocess.run(
14
+ ["git", "worktree", "add", "-b", branch, str(path), from_ref],
15
+ check=True,
16
+ capture_output=True,
17
+ text=True,
18
+ )
19
+ else:
20
+ subprocess.run(
21
+ ["git", "worktree", "add", "-b", branch, str(path), "HEAD"],
22
+ check=True,
23
+ capture_output=True,
24
+ text=True,
25
+ )
26
+
27
+
28
+ def remove_worktree(path: Path, *, force: bool = False) -> None:
29
+ """Remove a worktree."""
30
+ cmd = ["git", "worktree", "remove", str(path)]
31
+ if force:
32
+ cmd.append("--force")
33
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
34
+
35
+
36
+ def list_worktrees() -> list[dict[str, str]]:
37
+ """List all worktrees. Returns list of dicts with 'path', 'head', 'branch' keys."""
38
+ result = subprocess.run(
39
+ ["git", "worktree", "list", "--porcelain"],
40
+ check=True,
41
+ capture_output=True,
42
+ text=True,
43
+ )
44
+ worktrees = []
45
+ current: dict[str, str] = {}
46
+ for line in result.stdout.splitlines():
47
+ if line.startswith("worktree "):
48
+ if current:
49
+ worktrees.append(current)
50
+ current = {"path": str(Path(line.removeprefix("worktree ")))}
51
+ elif line.startswith("HEAD "):
52
+ current["head"] = line.removeprefix("HEAD ")
53
+ elif line.startswith("branch "):
54
+ current["branch"] = line.removeprefix("branch ").removeprefix("refs/heads/")
55
+ elif line == "bare":
56
+ current["bare"] = "true"
57
+ elif line == "detached":
58
+ current["detached"] = "true"
59
+ if current:
60
+ worktrees.append(current)
61
+ return worktrees
62
+
63
+
64
+ def find_repo_root() -> Path:
65
+ """Find the git repo root from cwd."""
66
+ result = subprocess.run(
67
+ ["git", "rev-parse", "--show-toplevel"],
68
+ check=True,
69
+ capture_output=True,
70
+ text=True,
71
+ )
72
+ return Path(result.stdout.strip())
73
+
74
+
75
+ def is_merged(branch: str) -> bool:
76
+ """Check if a branch has been merged into the main branch (not itself)."""
77
+ result = subprocess.run(
78
+ ["git", "branch", "--merged", "HEAD"],
79
+ check=True,
80
+ capture_output=True,
81
+ text=True,
82
+ )
83
+ # Exclude the current branch (prefixed with '*') — it's not "merged", it IS HEAD
84
+ merged = {
85
+ line.strip().lstrip("* ")
86
+ for line in result.stdout.splitlines()
87
+ if not line.startswith("*")
88
+ }
89
+ return branch in merged
90
+
91
+
92
+ def is_dirty(path: Path) -> bool:
93
+ """Check if a worktree has uncommitted changes."""
94
+ result = subprocess.run(
95
+ ["git", "status", "--porcelain"],
96
+ cwd=path,
97
+ check=True,
98
+ capture_output=True,
99
+ text=True,
100
+ )
101
+ return bool(result.stdout.strip())
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-worm
3
+ Version: 0.1.0
4
+ Summary: A better git worktree manager
5
+ Project-URL: Homepage, https://github.com/ThatXliner/git-worm
6
+ Project-URL: Repository, https://github.com/ThatXliner/git-worm
7
+ Project-URL: Issues, https://github.com/ThatXliner/git-worm/issues
8
+ Author-email: Bryan Hu <thatxliner@gmail.com>
9
+ License-Expression: Unlicense
10
+ License-File: LICENSE
11
+ Keywords: cli,git,worktree
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: The Unlicense (Unlicense)
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Version Control :: Git
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: xclif>=0.4.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # git-worm
24
+
25
+ > Git WORktree Manager
26
+
27
+ [![CI](https://github.com/ThatXliner/git-worm/actions/workflows/ci.yml/badge.svg)](https://github.com/ThatXliner/git-worm/actions/workflows/ci.yml)
28
+
29
+ https://github.com/user-attachments/assets/916af237-e959-43fc-9046-067929ad874f
30
+
31
+ A better git worktree manager. Built with [Xclif](https://github.com/ThatXliner/xclif).
32
+
33
+ ## Why?
34
+
35
+ `git worktree` is powerful but raw. git-worm adds:
36
+
37
+ - **Automatic file management** — gitignored files (`.env`, `.venv`, `node_modules`, etc.) are copied into new worktrees so switching feels like `git switch`
38
+ - **Smart package manager detection** — pnpm/bun/Yarn PnP users don't get unnecessary `node_modules` copies
39
+ - **Nice UI** — Rich-formatted output, tree views, colored status. You can also create multiple worktrees in one command!
40
+
41
+ ## Install
42
+
43
+ Requires Python 3.12+
44
+
45
+ ```bash
46
+ pip install git-worm
47
+ ```
48
+
49
+ Or with [uv](https://github.com/astral-sh/uv):
50
+
51
+ ```bash
52
+ uv tool install git-worm
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ Because of [Xclif](https://github.com/ThatXliner/xclif), git-worm automatically gets a very nice-looking help page
58
+
59
+ ![Help](./help.png)
60
+
61
+ ```bash
62
+ # Create a new worktree (copies .env, .venv, etc. automatically)
63
+ git worm new feat-login
64
+
65
+ # Create from a specific ref
66
+ git worm new feat-login --from-ref main
67
+
68
+ # List all worktrees
69
+ git worm list
70
+
71
+ # Print worktree path
72
+ git worm switch feat-login
73
+
74
+ # Remove a worktree
75
+ git worm rm feat-login
76
+
77
+ # Remove even if dirty
78
+ git worm rm feat-login --force
79
+
80
+ # Shell integration (add to .bashrc/.zshrc)
81
+ eval "$(git-worm shell-init)"
82
+ # Then: worm switch feat-login (auto-cds)
83
+ ```
84
+
85
+ ### Shell integration
86
+
87
+ `git-worm shell-init` outputs a `worm` shell function that wraps `git-worm`. This is needed because `worm switch` needs to `cd` into the worktree directory, which a plain subprocess can't do. All other commands are forwarded to `git-worm` as-is.
88
+
89
+ Add this to your `.bashrc` or `.zshrc`:
90
+
91
+ ```bash
92
+ eval "$(git-worm shell-init)"
93
+ ```
94
+
95
+ Then use `worm` instead of `git-worm`:
96
+
97
+ ```bash
98
+ worm new feat-login
99
+ worm switch feat-login # cds into the worktree
100
+ worm list
101
+ ```
102
+
103
+ ## Configuration
104
+
105
+ Optional `.git-worm.toml` in your repo root:
106
+
107
+ ```toml
108
+ [settings]
109
+ worktree_dir = ".worktrees" # default
110
+
111
+ [[share]]
112
+ path = ".env*"
113
+ strategy = "copy"
114
+
115
+ [[share]]
116
+ path = "node_modules"
117
+ strategy = "ignore"
118
+
119
+ [[share]]
120
+ path = "target"
121
+ strategy = "symlink"
122
+ ```
123
+
124
+ Strategies: `copy`, `reflink` (COW, falls back to copy), `symlink`, `ignore`.
125
+
126
+ When a config file is present, it replaces the default behavior entirely.
127
+
128
+ ## Default Behavior (no config)
129
+
130
+ 1. All gitignored files/dirs are detected
131
+ 2. `.git/` and `.worktrees/` are excluded
132
+ 3. Files are plain-copied, directories are reflinked (with copy fallback)
133
+ 4. `node_modules/` is skipped if pnpm, bun, or Yarn PnP is detected
134
+
135
+ ## License
136
+
137
+ Public Domain
@@ -0,0 +1,17 @@
1
+ git_worm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ git_worm/__main__.py,sha256=TmT9mB6g_kE8g4tlls_Kv002M302t6WDGVBrffpXgME,144
3
+ git_worm/config.py,sha256=ihoQ3yiiND8wPBTjbLUcj-ouPR7BBI0XGo9pdwuPpRo,888
4
+ git_worm/files.py,sha256=rJZ3qrZUnkqhZa_pg_JYkVTjfJSFB_A05K2iO7nTM6I,7840
5
+ git_worm/worktree.py,sha256=sG0gJ2jTaQ2SHAnCyHU65aet56mwWab0P7g2S3oqq-0,3099
6
+ git_worm/routes/__init__.py,sha256=TytV6xqzqcLISLrM4u-jmRvNlzOTNpUEsYraQ0qGeGI,107
7
+ git_worm/routes/list.py,sha256=MQKCliFQNzCs2MJuuenSn2vuyHuXwDbmLqOLEPSa0Lg,1340
8
+ git_worm/routes/new.py,sha256=roKfXSdvKLFGN-xWNOmTt4z2M1kvjgJFKSdGLdhxUO4,2798
9
+ git_worm/routes/prune.py,sha256=6bAUvOIQZK36N6_NAmdXiTDExIpXG3mKqwUkjpfsV7I,2098
10
+ git_worm/routes/remove.py,sha256=2c4a3LLx97byfQNYtXJHPB0mrvOe7f4139Xob_Mo2lM,1301
11
+ git_worm/routes/shell_init.py,sha256=mp8RTu9Ujt0QtLe4UQY8AbbPVIU7KIQ5ZPtgVcyOxB8,1213
12
+ git_worm/routes/switch.py,sha256=6SUqiZab0mO8QYSOV_pnJ3lLZJLYEzk7rMi8T_pbJaw,627
13
+ git_worm-0.1.0.dist-info/METADATA,sha256=yyrDCZsawDvXeYeTmnvPhPUWJbjWWCDMCRoB9TjU_7A,3644
14
+ git_worm-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ git_worm-0.1.0.dist-info/entry_points.txt,sha256=ArQorxkJPKfClAYLg44LYD_VEW6z3QgJzO40djPxJyo,51
16
+ git_worm-0.1.0.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
17
+ git_worm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-worm = git_worm.__main__:cli
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>