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 +0 -0
- git_worm/__main__.py +8 -0
- git_worm/config.py +35 -0
- git_worm/files.py +246 -0
- git_worm/routes/__init__.py +6 -0
- git_worm/routes/list.py +44 -0
- git_worm/routes/new.py +72 -0
- git_worm/routes/prune.py +72 -0
- git_worm/routes/remove.py +41 -0
- git_worm/routes/shell_init.py +37 -0
- git_worm/routes/switch.py +22 -0
- git_worm/worktree.py +101 -0
- git_worm-0.1.0.dist-info/METADATA +137 -0
- git_worm-0.1.0.dist-info/RECORD +17 -0
- git_worm-0.1.0.dist-info/WHEEL +4 -0
- git_worm-0.1.0.dist-info/entry_points.txt +2 -0
- git_worm-0.1.0.dist-info/licenses/LICENSE +24 -0
git_worm/__init__.py
ADDED
|
File without changes
|
git_worm/__main__.py
ADDED
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]
|
git_worm/routes/list.py
ADDED
|
@@ -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
|
git_worm/routes/prune.py
ADDED
|
@@ -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
|
+
[](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
|
+

|
|
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,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>
|