@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/bin/che.py +869 -0
  4. package/bin/commands.py +872 -0
  5. package/bin/drives/eject-all-drives.py +203 -0
  6. package/bin/drives/mount-all-drives.py +140 -0
  7. package/bin/drives/ping-nas.py +242 -0
  8. package/bin/files/compress-folders.py +188 -0
  9. package/bin/files/delete-by-ext.py +108 -0
  10. package/bin/files/delete-empty-folders.py +136 -0
  11. package/bin/files/delete-smb-files.py +105 -0
  12. package/bin/files/files-under-size.py +132 -0
  13. package/bin/files/find-largest-files.py +102 -0
  14. package/bin/files/make-alpha-dir.py +63 -0
  15. package/bin/git/all-actions.py +545 -0
  16. package/bin/git/checkout-my-branches.py +147 -0
  17. package/bin/git/clean-stale-branches.py +161 -0
  18. package/bin/git/prune-worktrees.py +143 -0
  19. package/bin/git/sync-all-branches.py +368 -0
  20. package/bin/git/update-local-branches.py +195 -0
  21. package/bin/install.py +1515 -0
  22. package/bin/shellgen.py +782 -0
  23. package/bin/system/btop-launch.py +114 -0
  24. package/bin/system/update-brew.py +146 -0
  25. package/bin/tui.py +648 -0
  26. package/bin/utils.py +836 -0
  27. package/bin/video/delete-duplicate-videos.py +313 -0
  28. package/bin/video/detect-green-magenta-videos.py +268 -0
  29. package/bin/video/find-movie-by-year.py +84 -0
  30. package/bin/video/find-video-mkv-issues.py +242 -0
  31. package/bin/video/fix-codecs.py +209 -0
  32. package/bin/video/largest-tv-shows.py +175 -0
  33. package/bin/video/remove-metadata.py +126 -0
  34. package/bin/video/rename-video-file.py +329 -0
  35. package/bin/video/scan-videos-audio-language.py +110 -0
  36. package/bin/video/show-codecs.py +111 -0
  37. package/bin/video/validate-video-files.py +114 -0
  38. package/bin/video/video-list.py +120 -0
  39. package/package.json +54 -0
  40. package/shell/che.bash +382 -0
  41. package/shell/che.fish +282 -0
  42. package/shell/che.zsh +389 -0
  43. package/shell/completions/_che +146 -0
  44. package/shell/completions/che.bash +107 -0
  45. 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)