Coverage for src/workstack/cli/commands/sync.py: 92%
106 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
1import os
2import subprocess
3from pathlib import Path
5import click
7from workstack.cli.commands.remove import _remove_worktree
8from workstack.cli.core import discover_repo_context, ensure_workstacks_dir, worktree_path_for
9from workstack.cli.shell_utils import render_cd_script, write_script_to_temp
10from workstack.core.context import WorkstackContext
13def _emit(message: str, *, script_mode: bool, error: bool = False) -> None:
14 """Emit a message to stdout or stderr based on script mode.
16 In script mode, ALL output goes to stderr (so the shell wrapper can capture
17 only the activation script from stdout). The `error` parameter has no effect
18 in script mode since everything is already sent to stderr.
20 In non-script mode, output goes to stdout by default, unless `error=True`.
22 Args:
23 message: Text to output.
24 script_mode: True when running in --script mode (all output to stderr).
25 error: Force stderr output in non-script mode (ignored in script mode).
26 """
27 click.echo(message, err=error or script_mode)
30def _return_to_original_worktree(
31 workstacks_dir: Path, current_worktree_name: str | None, *, script_mode: bool
32) -> None:
33 """Return to original worktree if it exists.
35 Only changes directory in non-script mode. In script mode, directory changes
36 are handled by shell wrapper executing the output script.
37 """
38 if current_worktree_name is None:
39 return
41 wt_path = worktree_path_for(workstacks_dir, current_worktree_name)
42 if not wt_path.exists():
43 return
45 _emit(f"\nReturning to: {current_worktree_name}", script_mode=script_mode)
46 # Only chdir in non-script mode; script output handles cd in script mode
47 if not script_mode:
48 os.chdir(wt_path)
51@click.command("sync")
52@click.option(
53 "-f",
54 "--force",
55 is_flag=True,
56 help="Pass --force to gt sync and automatically remove merged worktrees without confirmation.",
57)
58@click.option(
59 "--dry-run",
60 is_flag=True,
61 # dry_run=False: Allow destructive operations by default
62 default=False,
63 help="Show what would be done without executing destructive operations.",
64)
65@click.option(
66 "--script",
67 is_flag=True,
68 hidden=True,
69 help="Output shell script for directory change instead of messages.",
70)
71@click.pass_obj
72def sync_cmd(ctx: WorkstackContext, force: bool, dry_run: bool, script: bool) -> None:
73 """Sync with Graphite and clean up merged worktrees.
75 This command must be run from a workstack-managed repository.
77 Steps:
78 1. Verify graphite is enabled
79 2. Save current worktree location
80 3. Switch to root worktree (to avoid git checkout conflicts)
81 4. Run `gt sync [-f]` from root
82 5. Identify merged/closed workstacks
83 6. With -f: automatically remove worktrees without confirmation
84 7. Without -f: show deletable worktrees and prompt for confirmation
85 8. Return to original worktree (if it still exists)
86 """
88 # Step 1: Verify Graphite is enabled
89 use_graphite = ctx.global_config_ops.get_use_graphite()
90 if not use_graphite:
91 _emit(
92 "Error: 'workstack sync' requires Graphite. "
93 "Run 'workstack config set use-graphite true'",
94 script_mode=script,
95 error=True,
96 )
97 raise SystemExit(1)
99 # Step 2: Save current location
100 repo = discover_repo_context(ctx, Path.cwd())
101 workstacks_dir = ensure_workstacks_dir(repo)
103 # Determine current worktree (if any)
104 current_wt_path = Path.cwd().resolve()
105 current_worktree_name: str | None = None
107 if current_wt_path.parent == workstacks_dir:
108 current_worktree_name = current_wt_path.name
110 # Step 3: Switch to root (only if not already at root)
111 if Path.cwd().resolve() != repo.root:
112 _emit(f"Switching to root worktree: {repo.root}", script_mode=script)
113 os.chdir(repo.root)
115 # Step 4: Run `gt sync`
116 cmd = ["gt", "sync"]
117 if force:
118 cmd.append("-f")
120 if not dry_run:
121 _emit(f"Running: {' '.join(cmd)}", script_mode=script)
122 try:
123 ctx.graphite_ops.sync(repo.root, force=force)
124 except subprocess.CalledProcessError as e:
125 _emit(
126 f"Error: gt sync failed with exit code {e.returncode}",
127 script_mode=script,
128 error=True,
129 )
130 raise SystemExit(e.returncode) from e
131 except FileNotFoundError as e:
132 _emit(
133 "Error: 'gt' command not found. Install Graphite CLI: "
134 "brew install withgraphite/tap/graphite",
135 script_mode=script,
136 error=True,
137 )
138 raise SystemExit(1) from e
139 else:
140 _emit(f"[DRY RUN] Would run {' '.join(cmd)}", script_mode=script)
142 # Step 5: Identify deletable workstacks
143 worktrees = ctx.git_ops.list_worktrees(repo.root)
145 # Track workstacks eligible for deletion
146 deletable: list[tuple[str, str, str, int]] = []
148 for wt in worktrees:
149 # Skip root
150 if wt.path == repo.root:
151 continue
153 # Skip detached HEAD
154 if wt.branch is None:
155 continue
157 # Skip non-managed worktrees
158 if wt.path.parent != workstacks_dir:
159 continue
161 # Check PR status
162 state, pr_number, title = ctx.github_ops.get_pr_status(repo.root, wt.branch, debug=False)
164 if state in ("MERGED", "CLOSED") and pr_number is not None:
165 name = wt.path.name
166 deletable.append((name, wt.branch, state, pr_number))
168 # Step 6: Display and optionally clean
169 if not deletable:
170 _emit("\nNo workstacks to clean up.", script_mode=script)
171 else:
172 _emit("\nWorkstacks safe to delete:\n", script_mode=script)
174 for name, branch, state, pr_number in deletable:
175 # Display formatted (reuse gc.py formatting)
176 name_part = click.style(name, fg="cyan", bold=True)
177 branch_part = click.style(f"[{branch}]", fg="yellow")
178 state_part = click.style(state.lower(), fg="green" if state == "MERGED" else "red")
179 pr_part = click.style(f"PR #{pr_number}", fg="bright_black")
181 _emit(f" {name_part} {branch_part} - {state_part} ({pr_part})", script_mode=script)
183 _emit("", script_mode=script) # Blank line
185 # Confirm unless --force or --dry-run
186 if not force and not dry_run:
187 if not click.confirm(
188 f"Remove {len(deletable)} worktree(s)?", default=False, err=script
189 ):
190 _emit("Cleanup cancelled.", script_mode=script)
191 _return_to_original_worktree(
192 workstacks_dir, current_worktree_name, script_mode=script
193 )
194 return
196 # Remove each worktree
197 for name, _branch, _state, _pr_number in deletable:
198 if dry_run:
199 _emit(
200 f"[DRY RUN] Would remove worktree: {name} (branch: {_branch})",
201 script_mode=script,
202 )
203 else:
204 _emit(f"Removing worktree: {name} (branch: {_branch})", script_mode=script)
205 # Reuse remove logic from remove.py
206 _remove_worktree(
207 ctx,
208 name,
209 force=True, # Already confirmed above
210 delete_stack=False, # Leave branches for gt sync -f
211 dry_run=False,
212 )
214 # Step 6.5: Automatically run second gt sync -f to delete branches (when force=True)
215 if force and not dry_run and deletable:
216 _emit("\nDeleting merged branches...", script_mode=script)
217 ctx.graphite_ops.sync(repo.root, force=True)
218 _emit("✓ Merged branches deleted.", script_mode=script)
220 # Only show manual instruction if force was not used
221 if not force:
222 _emit(
223 "\nNext step: Run 'workstack sync -f' to automatically delete the merged branches.",
224 script_mode=script,
225 )
227 # Step 7: Return to original worktree
228 script_output_path: Path | None = None
230 if current_worktree_name:
231 wt_path = worktree_path_for(workstacks_dir, current_worktree_name)
233 # Check if worktree still exists
234 if wt_path.exists():
235 _emit(f"\nReturning to: {current_worktree_name}", script_mode=script)
236 if not script:
237 os.chdir(wt_path)
238 else:
239 # Generate cd script for shell wrapper
240 script_content = render_cd_script(
241 wt_path,
242 comment=f"return to {current_worktree_name}",
243 success_message=f"✓ Returned to {current_worktree_name}.",
244 )
245 script_output_path = write_script_to_temp(
246 script_content,
247 command_name="sync",
248 comment=f"return to {current_worktree_name}",
249 )
250 else:
251 _emit(
252 f"\n✓ Staying in root worktree (original worktree was deleted).\n"
253 f"💡 If you're still in the deleted directory, run: cd {repo.root}",
254 script_mode=script,
255 )
256 if script:
257 script_content = render_cd_script(
258 repo.root,
259 comment="return to root",
260 success_message="✓ Switched to root worktree.",
261 )
262 script_output_path = write_script_to_temp(
263 script_content,
264 command_name="sync",
265 comment="return to root",
266 )
268 # Output temp file path for shell wrapper
269 if script and script_output_path:
270 click.echo(str(script_output_path), nl=False)