@codebend3r/che 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/che.py +869 -0
- package/bin/commands.py +872 -0
- package/bin/drives/eject-all-drives.py +203 -0
- package/bin/drives/mount-all-drives.py +140 -0
- package/bin/drives/ping-nas.py +242 -0
- package/bin/files/compress-folders.py +188 -0
- package/bin/files/delete-by-ext.py +108 -0
- package/bin/files/delete-empty-folders.py +136 -0
- package/bin/files/delete-smb-files.py +105 -0
- package/bin/files/files-under-size.py +132 -0
- package/bin/files/find-largest-files.py +102 -0
- package/bin/files/make-alpha-dir.py +63 -0
- package/bin/git/all-actions.py +545 -0
- package/bin/git/checkout-my-branches.py +147 -0
- package/bin/git/clean-stale-branches.py +161 -0
- package/bin/git/prune-worktrees.py +143 -0
- package/bin/git/sync-all-branches.py +368 -0
- package/bin/git/update-local-branches.py +195 -0
- package/bin/install.py +1515 -0
- package/bin/shellgen.py +782 -0
- package/bin/system/btop-launch.py +114 -0
- package/bin/system/update-brew.py +146 -0
- package/bin/tui.py +648 -0
- package/bin/utils.py +836 -0
- package/bin/video/delete-duplicate-videos.py +313 -0
- package/bin/video/detect-green-magenta-videos.py +268 -0
- package/bin/video/find-movie-by-year.py +84 -0
- package/bin/video/find-video-mkv-issues.py +242 -0
- package/bin/video/fix-codecs.py +209 -0
- package/bin/video/largest-tv-shows.py +175 -0
- package/bin/video/remove-metadata.py +126 -0
- package/bin/video/rename-video-file.py +329 -0
- package/bin/video/scan-videos-audio-language.py +110 -0
- package/bin/video/show-codecs.py +111 -0
- package/bin/video/validate-video-files.py +114 -0
- package/bin/video/video-list.py +120 -0
- package/package.json +54 -0
- package/shell/che.bash +382 -0
- package/shell/che.fish +282 -0
- package/shell/che.zsh +389 -0
- package/shell/completions/_che +146 -0
- package/shell/completions/che.bash +107 -0
- package/shell/completions/che.fish +371 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""๐งน Delete local branches whose upstream is gone (the remote branch was deleted).
|
|
3
|
+
|
|
4
|
+
Protected branches and the current branch are never deleted.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
clean-stale-branches.py [--dry-run] [--protect=BRANCH1,BRANCH2,...]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--dry-run Show what would be deleted, change nothing
|
|
11
|
+
--protect=LIST Comma-separated extra branches to protect
|
|
12
|
+
(defaults: main master dev develop staging)
|
|
13
|
+
--help Show help
|
|
14
|
+
|
|
15
|
+
Env:
|
|
16
|
+
DRY_RUN=true Same as --dry-run (used by the .zshrc -dr wrapper)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
25
|
+
|
|
26
|
+
from utils import (
|
|
27
|
+
add_bool_flag,
|
|
28
|
+
build_parser,
|
|
29
|
+
env_bool,
|
|
30
|
+
info,
|
|
31
|
+
log,
|
|
32
|
+
note,
|
|
33
|
+
require_binary,
|
|
34
|
+
run,
|
|
35
|
+
run_cli,
|
|
36
|
+
run_lines,
|
|
37
|
+
success,
|
|
38
|
+
warning,
|
|
39
|
+
worktree_branch_map,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__version__ = "3.1.0"
|
|
43
|
+
|
|
44
|
+
DEFAULT_PROTECTED = ("main", "master", "dev", "develop", "staging")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def current_branch() -> str:
|
|
48
|
+
"""The checked-out branch, or "" when HEAD is detached."""
|
|
49
|
+
completed = run(
|
|
50
|
+
["git", "symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
51
|
+
check=False,
|
|
52
|
+
capture=True,
|
|
53
|
+
)
|
|
54
|
+
return (completed.stdout or "").strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def stale_branches(protected: set[str], skip: str) -> list[str]:
|
|
58
|
+
"""Local branches whose upstream is marked ``[gone]``.
|
|
59
|
+
|
|
60
|
+
``[gone]`` means the remote branch was deleted, which is what a merged and
|
|
61
|
+
tidied-up PR leaves behind. It does *not* mean merged - a closed-unmerged
|
|
62
|
+
PR looks identical here, same as in the shell version.
|
|
63
|
+
"""
|
|
64
|
+
# Separator is \x01, not the shell version's '|': a branch may legally be
|
|
65
|
+
# named `a|b[gone]`, which made the shell version split the record wrong
|
|
66
|
+
# and delete branch `a`. Control characters are illegal in ref names.
|
|
67
|
+
found = []
|
|
68
|
+
for line in run_lines(
|
|
69
|
+
["git", "for-each-ref", "--format=%(refname:short)%01%(upstream:track)", "refs/heads/"]
|
|
70
|
+
):
|
|
71
|
+
branch, _, track = line.partition("\x01")
|
|
72
|
+
if not branch:
|
|
73
|
+
continue
|
|
74
|
+
if branch in protected:
|
|
75
|
+
continue
|
|
76
|
+
if branch == skip:
|
|
77
|
+
note(f"โญ๏ธ Skipping current branch: {branch}")
|
|
78
|
+
continue
|
|
79
|
+
if "[gone]" in track:
|
|
80
|
+
found.append(branch)
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(argv: list[str] | None = None) -> int:
|
|
85
|
+
parser = build_parser(
|
|
86
|
+
prog="clean-stale-branches",
|
|
87
|
+
description="๐งน Delete local branches whose upstream is gone.",
|
|
88
|
+
)
|
|
89
|
+
add_bool_flag(
|
|
90
|
+
parser,
|
|
91
|
+
"--dry-run",
|
|
92
|
+
default=env_bool("DRY_RUN", False),
|
|
93
|
+
help="Show what would be deleted, change nothing",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--protect",
|
|
97
|
+
default="",
|
|
98
|
+
metavar="LIST",
|
|
99
|
+
help=f"Comma-separated extra branches to protect (defaults: {' '.join(DEFAULT_PROTECTED)})",
|
|
100
|
+
)
|
|
101
|
+
args = parser.parse_args(argv)
|
|
102
|
+
|
|
103
|
+
require_binary("git")
|
|
104
|
+
|
|
105
|
+
protected = set(DEFAULT_PROTECTED)
|
|
106
|
+
protected.update(name for name in args.protect.split(",") if name)
|
|
107
|
+
|
|
108
|
+
header_suffix = " ๐ต (dry run)" if args.dry_run else ""
|
|
109
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
110
|
+
note(f"๐งน clean-stale-branches{header_suffix}")
|
|
111
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
112
|
+
|
|
113
|
+
info("๐ก Fetching + pruning remotes...")
|
|
114
|
+
# Not wrapped: the shell version ran this bare under `set -e`, so a fetch
|
|
115
|
+
# failure exited with git's own status (128), not a flattened 1.
|
|
116
|
+
run(["git", "fetch", "--all", "--prune", "--quiet"])
|
|
117
|
+
|
|
118
|
+
info("๐ Scanning local branches for gone upstreams...")
|
|
119
|
+
stale = stale_branches(protected, current_branch())
|
|
120
|
+
|
|
121
|
+
if not stale:
|
|
122
|
+
success("โจ No stale branches. All clean! ๐")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
# A branch checked out in a worktree cannot be deleted โ git refuses with
|
|
126
|
+
# "already used by worktree". Split those out so one pinned branch doesn't
|
|
127
|
+
# abort the whole run; sync-all-branches collapses worktrees before it gets
|
|
128
|
+
# here, so this only bites when the script is run on its own.
|
|
129
|
+
# One `git worktree list` for the whole set, not one per branch as the
|
|
130
|
+
# shell's per-branch `worktree_for_branch` did.
|
|
131
|
+
pinned_by_branch = worktree_branch_map()
|
|
132
|
+
deletable = [branch for branch in stale if branch not in pinned_by_branch]
|
|
133
|
+
pinned = [(branch, pinned_by_branch[branch]) for branch in stale if branch in pinned_by_branch]
|
|
134
|
+
|
|
135
|
+
if pinned:
|
|
136
|
+
warning(f"โญ๏ธ {len(pinned)} stale branch(es) are checked out in a worktree:")
|
|
137
|
+
for branch, path in pinned:
|
|
138
|
+
warning(f" โข ๐ฟ {branch} (in {path})")
|
|
139
|
+
info("๐ก Run sync-all-branches to push and collapse those worktrees first.")
|
|
140
|
+
|
|
141
|
+
if not deletable:
|
|
142
|
+
success("โจ Nothing left to delete. ๐")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
if args.dry_run:
|
|
146
|
+
warning(f"๐ต {len(deletable)} stale branch(es) would be deleted:")
|
|
147
|
+
for branch in deletable:
|
|
148
|
+
warning(f" โข ๐ชฆ {branch}")
|
|
149
|
+
log("๐ก Dry run complete. Nothing was actually deleted.")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
for branch in deletable:
|
|
153
|
+
warning(f"๐๏ธ Deleting: {branch}")
|
|
154
|
+
run(["git", "branch", "-D", branch])
|
|
155
|
+
|
|
156
|
+
success(f"โจ Removed {len(deletable)} stale branch(es). ๐")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
run_cli(main)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""๐ฟ Remove every linked worktree in the current repository, then prune records.
|
|
3
|
+
|
|
4
|
+
The main worktree (the one containing .git) is always left untouched.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
prune-worktrees.py [--dry-run] [--force]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--dry-run Show what would be removed, change nothing
|
|
11
|
+
--force Pass --force to 'git worktree remove' (drops dirty trees)
|
|
12
|
+
--help Show help
|
|
13
|
+
|
|
14
|
+
Env:
|
|
15
|
+
DRY_RUN=true Same as --dry-run (used by the .zshrc -dr wrapper)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
24
|
+
|
|
25
|
+
from utils import (
|
|
26
|
+
add_bool_flag,
|
|
27
|
+
build_parser,
|
|
28
|
+
env_bool,
|
|
29
|
+
find_main_branch,
|
|
30
|
+
info,
|
|
31
|
+
log,
|
|
32
|
+
note,
|
|
33
|
+
require_binary,
|
|
34
|
+
run,
|
|
35
|
+
run_cli,
|
|
36
|
+
run_output,
|
|
37
|
+
success,
|
|
38
|
+
warning,
|
|
39
|
+
worktree_paths,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__version__ = "1.2.0"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv: list[str] | None = None) -> int:
|
|
46
|
+
parser = build_parser(
|
|
47
|
+
prog="prune-worktrees",
|
|
48
|
+
description="๐ฟ Remove every linked worktree, then prune stale admin records.",
|
|
49
|
+
)
|
|
50
|
+
add_bool_flag(
|
|
51
|
+
parser,
|
|
52
|
+
"--dry-run",
|
|
53
|
+
default=env_bool("DRY_RUN", True),
|
|
54
|
+
help="Show what would be removed, change nothing",
|
|
55
|
+
)
|
|
56
|
+
add_bool_flag(
|
|
57
|
+
parser,
|
|
58
|
+
"--force",
|
|
59
|
+
help="Pass --force to 'git worktree remove' (drops dirty trees)",
|
|
60
|
+
)
|
|
61
|
+
args = parser.parse_args(argv)
|
|
62
|
+
|
|
63
|
+
require_binary("git")
|
|
64
|
+
|
|
65
|
+
inside = run(["git", "rev-parse", "--is-inside-work-tree"], check=False, capture=True)
|
|
66
|
+
if inside.returncode != 0:
|
|
67
|
+
warning("โ Not inside a git working tree.")
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
header_suffix = " ๐ต (dry run)" if args.dry_run else ""
|
|
71
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
72
|
+
note(f"๐ฟ prune-worktrees{header_suffix}")
|
|
73
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
74
|
+
|
|
75
|
+
# Work from the main worktree: removing a worktree fails while standing
|
|
76
|
+
# inside it, and starting from a known-good branch keeps this predictable.
|
|
77
|
+
paths = worktree_paths()
|
|
78
|
+
if not paths:
|
|
79
|
+
warning("โ Could not determine main worktree.")
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
main_wt = Path(paths[0])
|
|
83
|
+
linked = [p for p in paths[1:] if Path(p) != main_wt]
|
|
84
|
+
|
|
85
|
+
main_branch = find_main_branch(main_wt)
|
|
86
|
+
if not main_branch:
|
|
87
|
+
warning("โ Neither 'main' nor 'master' exists locally โ refusing to prune.")
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
current = run_output(
|
|
91
|
+
["git", "symbolic-ref", "--quiet", "--short", "HEAD"], check=False, cwd=main_wt
|
|
92
|
+
).strip()
|
|
93
|
+
|
|
94
|
+
if current != main_branch:
|
|
95
|
+
# Only block on tracked-file changes: untracked files (including linked
|
|
96
|
+
# worktrees nested under the main worktree) won't be touched by checkout.
|
|
97
|
+
dirty = run_output(["git", "status", "--porcelain", "--untracked-files=no"], cwd=main_wt)
|
|
98
|
+
if dirty:
|
|
99
|
+
warning("โ Main worktree has uncommitted changes โ commit or stash before pruning. ๐งบ")
|
|
100
|
+
return 1
|
|
101
|
+
if args.dry_run:
|
|
102
|
+
info(
|
|
103
|
+
f"๐ต Would switch main worktree to ๐ฟ {main_branch} (currently on {current or 'detached'})"
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
info(f"๐ Switching main worktree to ๐ฟ {main_branch} (from {current or 'detached'})")
|
|
107
|
+
run(["git", "checkout", "--quiet", main_branch], cwd=main_wt)
|
|
108
|
+
else:
|
|
109
|
+
info(f"๐ Main worktree already on ๐ฟ {main_branch}")
|
|
110
|
+
|
|
111
|
+
info("๐ Listing worktrees...")
|
|
112
|
+
|
|
113
|
+
if not linked:
|
|
114
|
+
success("โจ No linked worktrees. Running prune anyway... ๐")
|
|
115
|
+
if args.dry_run:
|
|
116
|
+
run(["git", "worktree", "prune", "--dry-run", "--verbose"], check=False, cwd=main_wt)
|
|
117
|
+
log("๐ก Dry run complete. Nothing was actually pruned.")
|
|
118
|
+
else:
|
|
119
|
+
run(["git", "worktree", "prune", "--verbose"], cwd=main_wt)
|
|
120
|
+
success("โจ Pruned stale worktree records. ๐")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
if args.dry_run:
|
|
124
|
+
warning(f"๐ต {len(linked)} worktree(s) would be removed:")
|
|
125
|
+
for path in linked:
|
|
126
|
+
warning(f" โข ๐ชฆ {path}")
|
|
127
|
+
info("๐ Would also run: git worktree prune --verbose")
|
|
128
|
+
log("๐ก Dry run complete. Nothing was actually removed.")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
remove_args = ["--force"] if args.force else []
|
|
132
|
+
for path in linked:
|
|
133
|
+
warning(f"๐๏ธ Removing: {path}")
|
|
134
|
+
run(["git", "worktree", "remove", *remove_args, path], cwd=main_wt)
|
|
135
|
+
|
|
136
|
+
info("๐งน Pruning stale worktree records...")
|
|
137
|
+
run(["git", "worktree", "prune", "--verbose"], cwd=main_wt)
|
|
138
|
+
success(f"โจ Removed {len(linked)} worktree(s). ๐")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
run_cli(main)
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""๐ Sync everything: push every worktree, collapse the pushed ones, then tidy branches.
|
|
3
|
+
|
|
4
|
+
A worktree is a temporary workspace. Once its commits are on origin, the
|
|
5
|
+
directory holds nothing the remote does not, so it is removed and the branch
|
|
6
|
+
stays behind as a plain local branch that the later steps can update normally.
|
|
7
|
+
That also clears the "already used by worktree" pin, which is what otherwise
|
|
8
|
+
makes main un-checkoutable and stale branches un-deletable.
|
|
9
|
+
|
|
10
|
+
Steps, in order:
|
|
11
|
+
1. git fetch --all --prune
|
|
12
|
+
2. Push every linked worktree's branch to origin
|
|
13
|
+
3. Remove each pushed + clean worktree, keeping its branch
|
|
14
|
+
4. Prune stale worktree admin records
|
|
15
|
+
5. Switch the main clone to the main branch
|
|
16
|
+
6. clean-stale-branches (delete branches whose upstream is gone)
|
|
17
|
+
7. update-local-branches (rebase every branch onto its upstream)
|
|
18
|
+
8. checkout-my-branches (check out your remote branches that aren't local)
|
|
19
|
+
|
|
20
|
+
A worktree is only collapsed when it is clean AND fully pushed. Anything else
|
|
21
|
+
is kept and reported with the reason.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
sync-all-branches.py [--dry-run] [--no-push] [--keep-locked]
|
|
25
|
+
[--author=EMAIL] [--limit=N]
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--dry-run Report what would be pushed / removed / synced, change nothing
|
|
29
|
+
--no-push Never push; only collapse worktrees that are already pushed
|
|
30
|
+
--keep-locked Leave locked worktrees alone (default: release the lock once
|
|
31
|
+
the worktree is pushed and clean)
|
|
32
|
+
--author=EMAIL Passed through to checkout-my-branches
|
|
33
|
+
--limit=N Passed through to checkout-my-branches
|
|
34
|
+
--help Show help
|
|
35
|
+
|
|
36
|
+
Env:
|
|
37
|
+
DRY_RUN=true Same as --dry-run (used by the .zshrc -dr wrapper)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
46
|
+
|
|
47
|
+
from utils import (
|
|
48
|
+
add_bool_flag,
|
|
49
|
+
branch_is_pushed,
|
|
50
|
+
build_parser,
|
|
51
|
+
env_bool,
|
|
52
|
+
find_main_branch,
|
|
53
|
+
info,
|
|
54
|
+
log,
|
|
55
|
+
main_worktree,
|
|
56
|
+
note,
|
|
57
|
+
require_binary,
|
|
58
|
+
run,
|
|
59
|
+
run_cli,
|
|
60
|
+
run_output,
|
|
61
|
+
success,
|
|
62
|
+
warning,
|
|
63
|
+
worktree_branch_map,
|
|
64
|
+
worktree_is_clean,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
__version__ = "1.0.0"
|
|
68
|
+
|
|
69
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def delegate(script: str, args: list[str], *, cwd: Path, dry_run: bool) -> None:
|
|
73
|
+
"""Run a sibling script in the main worktree, aborting the sync if it fails.
|
|
74
|
+
|
|
75
|
+
The shell version invoked these under ``set -e``, so a non-zero exit from
|
|
76
|
+
any of them ended the run. ``check=True`` reproduces that: a branch that
|
|
77
|
+
could not be rebased is a reason to stop and look, not to sail on.
|
|
78
|
+
|
|
79
|
+
DRY_RUN is passed explicitly rather than inherited, so the child can never
|
|
80
|
+
disagree with the parent about which mode this is.
|
|
81
|
+
"""
|
|
82
|
+
run(
|
|
83
|
+
[sys.executable, str(SCRIPT_DIR / script), *args],
|
|
84
|
+
cwd=cwd,
|
|
85
|
+
env={"DRY_RUN": "true" if dry_run else "false"},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def collapse_worktrees(
|
|
90
|
+
*,
|
|
91
|
+
main_wt: Path,
|
|
92
|
+
push: bool,
|
|
93
|
+
keep_locked: bool,
|
|
94
|
+
dry_run: bool,
|
|
95
|
+
) -> tuple[list[str], list[str], list[str]]:
|
|
96
|
+
"""Push, then remove, every linked worktree that is clean and fully pushed.
|
|
97
|
+
|
|
98
|
+
Returns ``(pushed, collapsed, kept)`` for the summary. ``kept`` entries
|
|
99
|
+
carry the reason, because "nothing happened" is the one outcome a user
|
|
100
|
+
needs explained.
|
|
101
|
+
"""
|
|
102
|
+
pushed: list[str] = []
|
|
103
|
+
collapsed: list[str] = []
|
|
104
|
+
kept: list[str] = []
|
|
105
|
+
|
|
106
|
+
info("๐ฟ Inspecting linked worktrees...")
|
|
107
|
+
|
|
108
|
+
# Snapshot the map first: removing a worktree mutates the list git reports.
|
|
109
|
+
worktrees = [
|
|
110
|
+
(branch, path)
|
|
111
|
+
for branch, path in worktree_branch_map(cwd=main_wt).items()
|
|
112
|
+
if path and Path(path) != main_wt
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
if not worktrees:
|
|
116
|
+
info(" โน๏ธ No linked worktrees with a branch checked out.") # noqa: RUF001
|
|
117
|
+
|
|
118
|
+
for branch, path in worktrees:
|
|
119
|
+
# A dirty worktree holds work that only exists there. Never touch it.
|
|
120
|
+
if not worktree_is_clean(path):
|
|
121
|
+
kept.append(f"{branch} ({path}) โ dirty")
|
|
122
|
+
warning(f" โญ๏ธ {branch} โ dirty, keeping {path}")
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Push whatever is not on origin yet, so the directory becomes redundant.
|
|
126
|
+
if not branch_is_pushed(branch, cwd=main_wt):
|
|
127
|
+
if not push:
|
|
128
|
+
kept.append(f"{branch} ({path}) โ unpushed, --no-push")
|
|
129
|
+
warning(f" โญ๏ธ {branch} โ unpushed and --no-push, keeping {path}")
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if dry_run:
|
|
133
|
+
info(f" ๐ต Would push: {branch} โ origin")
|
|
134
|
+
pushed.append(branch)
|
|
135
|
+
else:
|
|
136
|
+
log(f" โฌ๏ธ Pushing {branch} โ origin")
|
|
137
|
+
result = run(
|
|
138
|
+
["git", "-C", path, "push", "--quiet", "--set-upstream", "origin", branch],
|
|
139
|
+
check=False,
|
|
140
|
+
)
|
|
141
|
+
if result.returncode != 0:
|
|
142
|
+
kept.append(f"{branch} ({path}) โ push failed")
|
|
143
|
+
warning(f" โ push failed for {branch}, keeping {path}")
|
|
144
|
+
continue
|
|
145
|
+
pushed.append(branch)
|
|
146
|
+
|
|
147
|
+
# Re-check rather than assume: a push can succeed and still leave the
|
|
148
|
+
# branch behind its upstream if someone else pushed in between.
|
|
149
|
+
if not dry_run and not branch_is_pushed(branch, cwd=main_wt):
|
|
150
|
+
kept.append(f"{branch} ({path}) โ still not fully pushed")
|
|
151
|
+
warning(f" โญ๏ธ {branch} โ still not fully pushed, keeping {path}")
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if dry_run:
|
|
155
|
+
info(f" ๐ต Would collapse: {path} (branch {branch} kept)")
|
|
156
|
+
collapsed.append(f"{branch} ({path})")
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# A lock is metadata stamped by whatever created the worktree (a tool,
|
|
160
|
+
# an agent, the user), and `git worktree remove` refuses while one is
|
|
161
|
+
# set. The lock says nothing about unsaved work โ the clean and pushed
|
|
162
|
+
# gates above are what protect that โ so release it rather than
|
|
163
|
+
# skipping. This is NOT --force, which is the flag that discards work.
|
|
164
|
+
if not keep_locked:
|
|
165
|
+
run(["git", "worktree", "unlock", path], check=False, capture=True, cwd=main_wt)
|
|
166
|
+
|
|
167
|
+
removed = run(["git", "worktree", "remove", path], check=False, cwd=main_wt)
|
|
168
|
+
if removed.returncode == 0:
|
|
169
|
+
success(f" โ
Collapsed {path} โ branch {branch} kept")
|
|
170
|
+
collapsed.append(f"{branch} ({path})")
|
|
171
|
+
else:
|
|
172
|
+
kept.append(f"{branch} ({path}) โ removal failed (locked?)")
|
|
173
|
+
warning(f" โ Could not remove {path}")
|
|
174
|
+
|
|
175
|
+
return pushed, collapsed, kept
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def report(
|
|
179
|
+
*,
|
|
180
|
+
pushed: list[str],
|
|
181
|
+
collapsed: list[str],
|
|
182
|
+
kept: list[str],
|
|
183
|
+
started_in: Path,
|
|
184
|
+
main_wt: Path,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Print the worktree summary."""
|
|
187
|
+
print()
|
|
188
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
189
|
+
success("โโโ worktree summary โโโ")
|
|
190
|
+
success(f"โฌ๏ธ Pushed: {len(pushed)}")
|
|
191
|
+
for branch in pushed:
|
|
192
|
+
success(f" โข ๐ฟ {branch}")
|
|
193
|
+
success(f"๐ฆ Collapsed: {len(collapsed)}")
|
|
194
|
+
for branch in collapsed:
|
|
195
|
+
success(f" โข ๐ชฆ {branch}")
|
|
196
|
+
if kept:
|
|
197
|
+
warning(f"โญ๏ธ Kept: {len(kept)}")
|
|
198
|
+
for branch in kept:
|
|
199
|
+
warning(f" โข ๐ฟ {branch}")
|
|
200
|
+
|
|
201
|
+
if started_in != main_wt:
|
|
202
|
+
info(f"๐ Started in {started_in}; finished in {main_wt}.")
|
|
203
|
+
if not started_in.is_dir():
|
|
204
|
+
warning(f"โ ๏ธ {started_in} was collapsed โ your shell is in a stale directory.")
|
|
205
|
+
warning(f" Run: cd {main_wt}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv: list[str] | None = None) -> int:
|
|
209
|
+
parser = build_parser(
|
|
210
|
+
prog="sync-all-branches",
|
|
211
|
+
description="๐ Push every worktree, collapse the pushed ones, then tidy branches.",
|
|
212
|
+
)
|
|
213
|
+
add_bool_flag(
|
|
214
|
+
parser,
|
|
215
|
+
"--dry-run",
|
|
216
|
+
default=env_bool("DRY_RUN", False),
|
|
217
|
+
help="Report what would be pushed / removed / synced, change nothing",
|
|
218
|
+
)
|
|
219
|
+
add_bool_flag(
|
|
220
|
+
parser,
|
|
221
|
+
"--no-push",
|
|
222
|
+
allow_value=False,
|
|
223
|
+
help="Never push; only collapse worktrees that are already pushed",
|
|
224
|
+
)
|
|
225
|
+
add_bool_flag(
|
|
226
|
+
parser,
|
|
227
|
+
"--keep-locked",
|
|
228
|
+
help="Leave locked worktrees alone (default: release the lock once pushed and clean)",
|
|
229
|
+
)
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
"--author",
|
|
232
|
+
default="",
|
|
233
|
+
metavar="EMAIL",
|
|
234
|
+
help="Passed through to checkout-my-branches",
|
|
235
|
+
)
|
|
236
|
+
parser.add_argument(
|
|
237
|
+
"--limit",
|
|
238
|
+
default="",
|
|
239
|
+
metavar="N",
|
|
240
|
+
help="Passed through to checkout-my-branches",
|
|
241
|
+
)
|
|
242
|
+
args = parser.parse_args(argv)
|
|
243
|
+
|
|
244
|
+
require_binary("git")
|
|
245
|
+
|
|
246
|
+
inside = run(["git", "rev-parse", "--is-inside-work-tree"], check=False, capture=True)
|
|
247
|
+
if inside.returncode != 0:
|
|
248
|
+
warning("โ Not inside a git working tree.")
|
|
249
|
+
return 1
|
|
250
|
+
|
|
251
|
+
header_suffix = " ๐ต (dry run)" if args.dry_run else ""
|
|
252
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
253
|
+
note(f"๐ sync-all-branches{header_suffix}")
|
|
254
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
255
|
+
|
|
256
|
+
# Every later step runs from the main worktree: a worktree cannot be
|
|
257
|
+
# removed while we are standing inside it, and the delegated scripts
|
|
258
|
+
# expect the main clone.
|
|
259
|
+
main_wt_raw = main_worktree()
|
|
260
|
+
if not main_wt_raw:
|
|
261
|
+
warning("โ Could not determine main worktree.")
|
|
262
|
+
return 1
|
|
263
|
+
|
|
264
|
+
main_wt = Path(main_wt_raw)
|
|
265
|
+
# The shell version `cd`-ed here. Passing cwd= to each call instead keeps
|
|
266
|
+
# the process's own directory untouched, so a caller that sourced this in
|
|
267
|
+
# the same shell would not be moved out from under itself.
|
|
268
|
+
started_in = Path.cwd().resolve()
|
|
269
|
+
|
|
270
|
+
main_branch = find_main_branch(main_wt)
|
|
271
|
+
if not main_branch:
|
|
272
|
+
warning("โ Neither 'main' nor 'master' exists locally โ refusing to sync.")
|
|
273
|
+
return 1
|
|
274
|
+
|
|
275
|
+
# -----------------------------------------------------------------------
|
|
276
|
+
# Step 1 โ fetch + prune
|
|
277
|
+
# -----------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
info("๐ก Fetching + pruning remotes...")
|
|
280
|
+
if args.dry_run:
|
|
281
|
+
info("๐ต Would run: git fetch --all --prune")
|
|
282
|
+
else:
|
|
283
|
+
run(["git", "fetch", "--all", "--prune", "--quiet"], cwd=main_wt)
|
|
284
|
+
|
|
285
|
+
# -----------------------------------------------------------------------
|
|
286
|
+
# Steps 2-3 โ push every worktree, then collapse the pushed ones
|
|
287
|
+
# -----------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
pushed, collapsed, kept = collapse_worktrees(
|
|
290
|
+
main_wt=main_wt,
|
|
291
|
+
push=not args.no_push,
|
|
292
|
+
keep_locked=args.keep_locked,
|
|
293
|
+
dry_run=args.dry_run,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# -----------------------------------------------------------------------
|
|
297
|
+
# Step 4 โ prune stale admin records
|
|
298
|
+
# -----------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
if args.dry_run:
|
|
301
|
+
info("๐ต Would run: git worktree prune")
|
|
302
|
+
else:
|
|
303
|
+
run(["git", "worktree", "prune"], cwd=main_wt)
|
|
304
|
+
|
|
305
|
+
# -----------------------------------------------------------------------
|
|
306
|
+
# Step 5 โ park on main before anything tries to delete or rebase branches
|
|
307
|
+
# -----------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
current = run_output(
|
|
310
|
+
["git", "symbolic-ref", "--quiet", "--short", "HEAD"], check=False, cwd=main_wt
|
|
311
|
+
).strip()
|
|
312
|
+
|
|
313
|
+
if current != main_branch:
|
|
314
|
+
# Only tracked changes block: untracked files (including worktrees
|
|
315
|
+
# nested under the main worktree) are not touched by checkout.
|
|
316
|
+
if run_output(["git", "status", "--porcelain", "--untracked-files=no"], cwd=main_wt):
|
|
317
|
+
warning("โ Main worktree has uncommitted changes โ commit or stash first. ๐งบ")
|
|
318
|
+
return 1
|
|
319
|
+
|
|
320
|
+
if args.dry_run:
|
|
321
|
+
info(f"๐ต Would switch to ๐ฟ {main_branch} (currently on {current or 'detached'})")
|
|
322
|
+
else:
|
|
323
|
+
info(f"๐ Switching to ๐ฟ {main_branch} (from {current or 'detached'})")
|
|
324
|
+
run(["git", "checkout", "--quiet", main_branch], cwd=main_wt)
|
|
325
|
+
else:
|
|
326
|
+
info(f"๐ Already on ๐ฟ {main_branch}")
|
|
327
|
+
|
|
328
|
+
# -----------------------------------------------------------------------
|
|
329
|
+
# Steps 6-8 โ delegate to the single-purpose scripts
|
|
330
|
+
# -----------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
delegate_args = ["--dry-run"] if args.dry_run else []
|
|
333
|
+
|
|
334
|
+
# DELIBERATE DIVERGENCE: the summary is printed even when a delegate fails.
|
|
335
|
+
# The pushes and collapses above already happened; aborting without saying
|
|
336
|
+
# which ones would leave the user guessing what state the repo is in.
|
|
337
|
+
try:
|
|
338
|
+
print()
|
|
339
|
+
delegate("clean-stale-branches.py", delegate_args, cwd=main_wt, dry_run=args.dry_run)
|
|
340
|
+
|
|
341
|
+
print()
|
|
342
|
+
delegate("update-local-branches.py", delegate_args, cwd=main_wt, dry_run=args.dry_run)
|
|
343
|
+
|
|
344
|
+
checkout_args = []
|
|
345
|
+
if args.author:
|
|
346
|
+
checkout_args.append(f"--author={args.author}")
|
|
347
|
+
if args.limit:
|
|
348
|
+
checkout_args.append(f"--limit={args.limit}")
|
|
349
|
+
|
|
350
|
+
print()
|
|
351
|
+
if args.dry_run:
|
|
352
|
+
info(f"๐ต Would run: checkout-my-branches {' '.join(checkout_args)}".rstrip())
|
|
353
|
+
else:
|
|
354
|
+
delegate("checkout-my-branches.py", checkout_args, cwd=main_wt, dry_run=args.dry_run)
|
|
355
|
+
except BaseException:
|
|
356
|
+
report(
|
|
357
|
+
pushed=pushed, collapsed=collapsed, kept=kept, started_in=started_in, main_wt=main_wt
|
|
358
|
+
)
|
|
359
|
+
raise
|
|
360
|
+
|
|
361
|
+
report(pushed=pushed, collapsed=collapsed, kept=kept, started_in=started_in, main_wt=main_wt)
|
|
362
|
+
|
|
363
|
+
log("๐ Sync complete.")
|
|
364
|
+
return 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
if __name__ == "__main__":
|
|
368
|
+
run_cli(main)
|