worktree-flash 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: astral-sh/setup-uv@v4
18
+
19
+ - run: uv build
20
+
21
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ *.egg-info/
5
+ .flash/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Will Twait
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: worktree-flash
3
+ Version: 0.1.0
4
+ Summary: Preview worktree branches from your main checkout while safely stashing your working state.
5
+ Project-URL: Homepage, https://github.com/WillTwait/flash
6
+ Project-URL: Repository, https://github.com/WillTwait/flash
7
+ Author: Will Twait
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,git,stash,worktree
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Topic :: Software Development :: Version Control :: Git
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: typer>=0.15.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ <h1 align="center">⚡ flash</h1>
21
+
22
+ <p align="center">
23
+ Preview worktree branches from your main checkout while safely stashing your working state.
24
+ </p>
25
+
26
+ <p align="center">
27
+ <img src="assets/cli.png" alt="flash cli" width="700">
28
+ </p>
29
+
30
+ ## Why
31
+
32
+ Worktrees are great for parallelizing work, but your dev environment — backend servers, locally running frontend, etc — lives in your main checkout. When you need to test changes on a worktree, you're stuck doing a manual stash-checkout-test-restore dance, carefully tracking not to throw away diffs on either branch.
33
+
34
+ `flash` does the whole dance in one command and tracks every step so nothing gets lost.
35
+
36
+ Inspired by [Conductor's Spotlight](https://docs.conductor.build/guides/spotlight-testing).
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ uv tool install worktree-flash # or pipx install worktree-flash
42
+ ```
43
+
44
+ Or from source:
45
+
46
+ ```bash
47
+ git clone https://github.com/WillTwait/flash.git
48
+ uv tool install ./flash
49
+ ```
50
+
51
+ Requires Python 3.10+.
52
+
53
+ ## Commands
54
+
55
+ | Command | Alias | Description |
56
+ | --------------------- | ---------- | -------------------------------------------------- |
57
+ | `flash into [name]` | `flash i` | Switch to a worktree's branch (or open fzf picker) |
58
+ | `flash out` | `flash o` | End flash, restore original branch + stash |
59
+ | `flash out --apply` | | End flash, send changes to worktree first |
60
+ | `flash out --discard` | | End flash, throw away changes |
61
+ | `flash apply` | `flash a` | Send changes to worktree without ending flash |
62
+ | `flash status` | `flash st` | Show current flash state |
63
+
64
+ ## Typical workflow
65
+
66
+ <img src="assets/demo.gif" alt="flash workflow demo" width="700">
67
+
68
+ ```bash
69
+ flash into my-worktree # stash, checkout worktree branch
70
+ # run server, test, poke around, fix things, commit
71
+ flash apply # cherry-pick commits + sync files back to worktree
72
+ # keep testing
73
+ flash out # restore original branch, pop stash, clean up
74
+ ```
75
+
76
+ ## Details
77
+
78
+ `flash into` — switch your checkout to a worktree's branch
79
+
80
+ 1. Stash uncommitted changes (tracked by SHA)
81
+ 2. Create and checkout a temp branch at the worktree's HEAD
82
+ 3. Copy the worktree's uncommitted changes into your checkout
83
+ 4. Write `.flash/state.json` to track everything
84
+
85
+ Pass a worktree name or branch, or omit for an fzf picker.
86
+
87
+ `flash apply` — send your changes back to the worktree
88
+
89
+ 1. Back up the worktree's state via `git stash create`
90
+ 2. Clean the worktree for a conflict-free cherry-pick
91
+ 3. Cherry-pick new commits into the worktree's history
92
+ 4. Copy uncommitted file changes to the worktree
93
+
94
+ Safe to run multiple times. If anything fails, the backup stash SHA is printed for recovery.
95
+
96
+ `flash out` — restore your original branch
97
+
98
+ 1. Prompt `[a]pply / [d]iscard` if you have unsent changes
99
+ 2. Checkout your original branch
100
+ 3. Delete the temp branch
101
+ 4. Pop your stash by SHA
102
+ 5. Remove `.flash/` entirely
103
+
104
+ ## Usage with Claude Code
105
+
106
+ Add this to your project's `CLAUDE.md` so Claude understands how to use flash:
107
+
108
+ ```markdown
109
+ ## Flash (worktree previewing)
110
+
111
+ `flash` is installed and available for previewing worktree branches from the main checkout.
112
+
113
+ - `flash into <name>` — switch to a worktree branch (stashes current work automatically)
114
+ - `flash out --discard` — restore original branch (use `--apply` to send changes back to worktree)
115
+ - `flash apply` — send commits + uncommitted changes to the worktree without ending the flash
116
+ - `flash status` — check current flash state
117
+
118
+ When you need to test or review code from a worktree, use `flash into` instead of manually
119
+ checking out branches. Always `flash out` when done.
120
+ ```
121
+
122
+ ## Safety
123
+
124
+ | Risk | Mitigation |
125
+ | ------------------------------ | ---------------------------------------------------- |
126
+ | Losing stashed changes | Tracked by SHA, not index position |
127
+ | Losing worktree state on apply | `git stash create` backup before any destructive op |
128
+ | Double flash | Refused if already flashed in |
129
+ | Branch conflict with worktree | Uses `flash/<branch>` temp branch |
130
+ | Interrupted mid-operation | State file has everything needed for manual recovery |
131
+ | Non-interactive (CI/agents) | Defaults to `--discard` with a warning |
@@ -0,0 +1,112 @@
1
+ <h1 align="center">⚡ flash</h1>
2
+
3
+ <p align="center">
4
+ Preview worktree branches from your main checkout while safely stashing your working state.
5
+ </p>
6
+
7
+ <p align="center">
8
+ <img src="assets/cli.png" alt="flash cli" width="700">
9
+ </p>
10
+
11
+ ## Why
12
+
13
+ Worktrees are great for parallelizing work, but your dev environment — backend servers, locally running frontend, etc — lives in your main checkout. When you need to test changes on a worktree, you're stuck doing a manual stash-checkout-test-restore dance, carefully tracking not to throw away diffs on either branch.
14
+
15
+ `flash` does the whole dance in one command and tracks every step so nothing gets lost.
16
+
17
+ Inspired by [Conductor's Spotlight](https://docs.conductor.build/guides/spotlight-testing).
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ uv tool install worktree-flash # or pipx install worktree-flash
23
+ ```
24
+
25
+ Or from source:
26
+
27
+ ```bash
28
+ git clone https://github.com/WillTwait/flash.git
29
+ uv tool install ./flash
30
+ ```
31
+
32
+ Requires Python 3.10+.
33
+
34
+ ## Commands
35
+
36
+ | Command | Alias | Description |
37
+ | --------------------- | ---------- | -------------------------------------------------- |
38
+ | `flash into [name]` | `flash i` | Switch to a worktree's branch (or open fzf picker) |
39
+ | `flash out` | `flash o` | End flash, restore original branch + stash |
40
+ | `flash out --apply` | | End flash, send changes to worktree first |
41
+ | `flash out --discard` | | End flash, throw away changes |
42
+ | `flash apply` | `flash a` | Send changes to worktree without ending flash |
43
+ | `flash status` | `flash st` | Show current flash state |
44
+
45
+ ## Typical workflow
46
+
47
+ <img src="assets/demo.gif" alt="flash workflow demo" width="700">
48
+
49
+ ```bash
50
+ flash into my-worktree # stash, checkout worktree branch
51
+ # run server, test, poke around, fix things, commit
52
+ flash apply # cherry-pick commits + sync files back to worktree
53
+ # keep testing
54
+ flash out # restore original branch, pop stash, clean up
55
+ ```
56
+
57
+ ## Details
58
+
59
+ `flash into` — switch your checkout to a worktree's branch
60
+
61
+ 1. Stash uncommitted changes (tracked by SHA)
62
+ 2. Create and checkout a temp branch at the worktree's HEAD
63
+ 3. Copy the worktree's uncommitted changes into your checkout
64
+ 4. Write `.flash/state.json` to track everything
65
+
66
+ Pass a worktree name or branch, or omit for an fzf picker.
67
+
68
+ `flash apply` — send your changes back to the worktree
69
+
70
+ 1. Back up the worktree's state via `git stash create`
71
+ 2. Clean the worktree for a conflict-free cherry-pick
72
+ 3. Cherry-pick new commits into the worktree's history
73
+ 4. Copy uncommitted file changes to the worktree
74
+
75
+ Safe to run multiple times. If anything fails, the backup stash SHA is printed for recovery.
76
+
77
+ `flash out` — restore your original branch
78
+
79
+ 1. Prompt `[a]pply / [d]iscard` if you have unsent changes
80
+ 2. Checkout your original branch
81
+ 3. Delete the temp branch
82
+ 4. Pop your stash by SHA
83
+ 5. Remove `.flash/` entirely
84
+
85
+ ## Usage with Claude Code
86
+
87
+ Add this to your project's `CLAUDE.md` so Claude understands how to use flash:
88
+
89
+ ```markdown
90
+ ## Flash (worktree previewing)
91
+
92
+ `flash` is installed and available for previewing worktree branches from the main checkout.
93
+
94
+ - `flash into <name>` — switch to a worktree branch (stashes current work automatically)
95
+ - `flash out --discard` — restore original branch (use `--apply` to send changes back to worktree)
96
+ - `flash apply` — send commits + uncommitted changes to the worktree without ending the flash
97
+ - `flash status` — check current flash state
98
+
99
+ When you need to test or review code from a worktree, use `flash into` instead of manually
100
+ checking out branches. Always `flash out` when done.
101
+ ```
102
+
103
+ ## Safety
104
+
105
+ | Risk | Mitigation |
106
+ | ------------------------------ | ---------------------------------------------------- |
107
+ | Losing stashed changes | Tracked by SHA, not index position |
108
+ | Losing worktree state on apply | `git stash create` backup before any destructive op |
109
+ | Double flash | Refused if already flashed in |
110
+ | Branch conflict with worktree | Uses `flash/<branch>` temp branch |
111
+ | Interrupted mid-operation | State file has everything needed for manual recovery |
112
+ | Non-interactive (CI/agents) | Defaults to `--discard` with a warning |
Binary file
Binary file
@@ -0,0 +1,38 @@
1
+ Output assets/demo.gif
2
+ Set Width 800
3
+ Set Height 500
4
+ Set FontSize 14
5
+ Set Padding 20
6
+ Set Shell "zsh"
7
+
8
+ Hide
9
+ Type "cd ~/Developer/flash-demo"
10
+ Enter
11
+ Sleep 1s
12
+ Show
13
+
14
+ # Flash into — fzf picker
15
+ Type "flash into"
16
+ Enter
17
+ Sleep 1s
18
+ Up
19
+ Sleep 500ms
20
+ Up
21
+ Sleep 500ms
22
+ Enter
23
+ Sleep 2s
24
+
25
+ # Check status
26
+ Type "flash status"
27
+ Enter
28
+ Sleep 2s
29
+
30
+ # Apply changes to worktree
31
+ Type "flash apply"
32
+ Enter
33
+ Sleep 2s
34
+
35
+ # Flash out
36
+ Type "flash out --discard"
37
+ Enter
38
+ Sleep 2s
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "worktree-flash"
3
+ version = "0.1.0"
4
+ description = "Preview worktree branches from your main checkout while safely stashing your working state."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Will Twait" }]
9
+ keywords = ["git", "worktree", "cli", "stash"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Topic :: Software Development :: Version Control :: Git",
16
+ ]
17
+ dependencies = [
18
+ "typer>=0.15.0",
19
+ ]
20
+
21
+ [project.scripts]
22
+ flash = "flash.cli:app"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/WillTwait/flash"
26
+ Repository = "https://github.com/WillTwait/flash"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/flash"]
File without changes
@@ -0,0 +1,356 @@
1
+ """Typer CLI entry point for flash."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from flash.core import (
11
+ FlashError,
12
+ checkout_branch,
13
+ cherry_pick_to_worktree,
14
+ clean_working_tree,
15
+ create_and_checkout_temp_branch,
16
+ delete_branch,
17
+ ensure_git_exclude,
18
+ fzf_pick_worktree,
19
+ get_canonical_root,
20
+ get_commits_since,
21
+ get_current_branch,
22
+ get_head_sha,
23
+ is_dirty,
24
+ list_worktrees,
25
+ pop_stash_by_sha,
26
+ resolve_worktree,
27
+ stash_changes,
28
+ stash_create,
29
+ sync_changes,
30
+ )
31
+ from flash.state import FlashState, clear_state, now_iso, read_state, write_state
32
+
33
+ app = typer.Typer(
34
+ help="Safely swap your main checkout to a worktree branch and back.",
35
+ no_args_is_help=True,
36
+ )
37
+
38
+
39
+ def _err(msg: str) -> None:
40
+ typer.secho(msg, fg=typer.colors.RED, err=True)
41
+
42
+
43
+ def _ok(msg: str) -> None:
44
+ typer.secho(msg, fg=typer.colors.GREEN)
45
+
46
+
47
+ def _info(msg: str) -> None:
48
+ typer.secho(msg, fg=typer.colors.YELLOW)
49
+
50
+
51
+ def _apply_to_worktree(state: FlashState, canonical_root: str) -> None:
52
+ """Cherry-pick commits and sync unstaged files to the worktree.
53
+
54
+ Strategy:
55
+ 1. Safety backup: `git stash create` in worktree (read-only)
56
+ 2. Clean the worktree so cherry-pick can't conflict
57
+ 3. Cherry-pick new commits onto clean worktree
58
+ 4. Copy uncommitted files from canonical → worktree
59
+ 5. Update state so next apply only picks new commits
60
+ """
61
+
62
+ commits = get_commits_since(state.flash_base_sha, cwd=canonical_root)
63
+ has_uncommitted = is_dirty(cwd=canonical_root)
64
+
65
+ if not commits and not has_uncommitted:
66
+ _info("No changes to apply.")
67
+ return
68
+
69
+ # Safety backup of worktree state (read-only, no side effects)
70
+ safety_sha = stash_create(cwd=state.worktree_path)
71
+
72
+ try:
73
+ if commits:
74
+ # Clean worktree for conflict-free cherry-pick
75
+ clean_working_tree(cwd=state.worktree_path)
76
+ cherry_pick_to_worktree(commits, state.worktree_path)
77
+ _ok(f"Cherry-picked {len(commits)} commit(s) to worktree.")
78
+
79
+ # Update base SHA so next apply only picks new commits
80
+ new_base = get_head_sha(cwd=canonical_root)
81
+ state.flash_base_sha = new_base
82
+ write_state(state)
83
+
84
+ if has_uncommitted:
85
+ synced = sync_changes("HEAD", canonical_root, state.worktree_path)
86
+ if synced:
87
+ _ok(f"Synced {len(synced)} uncommitted file(s) to worktree:")
88
+ for f in synced:
89
+ typer.echo(f" {f}")
90
+
91
+ except FlashError:
92
+ if safety_sha:
93
+ _err(f"Worktree state backed up as stash {safety_sha}")
94
+ _err(
95
+ f" Recover with: cd {state.worktree_path} && git stash apply {safety_sha}"
96
+ )
97
+ raise
98
+
99
+
100
+ def _complete_worktree_name(incomplete: str) -> list[str]:
101
+ """Tab-completion for worktree names."""
102
+ try:
103
+ canonical_root = get_canonical_root()
104
+ except FlashError:
105
+ return []
106
+ worktrees = list_worktrees(cwd=canonical_root)
107
+ names = []
108
+ for wt in worktrees:
109
+ if wt.is_bare or wt.path == canonical_root:
110
+ continue
111
+ dir_name = Path(wt.path).name
112
+ if incomplete in dir_name:
113
+ names.append(dir_name)
114
+ elif incomplete in wt.branch:
115
+ names.append(wt.branch)
116
+ return names
117
+
118
+
119
+ @app.command()
120
+ def into(
121
+ name: str | None = typer.Argument(
122
+ None,
123
+ help="Worktree directory name or branch name",
124
+ autocompletion=_complete_worktree_name,
125
+ ),
126
+ ) -> None:
127
+ """Flash into a worktree branch on the canonical checkout. [magenta]\\[alias: i][/magenta]"""
128
+ try:
129
+ canonical_root = get_canonical_root()
130
+ except FlashError as e:
131
+ _err(str(e))
132
+ raise typer.Exit(1)
133
+
134
+ # Check if already flashed in
135
+ existing = read_state(canonical_root)
136
+ if existing is not None:
137
+ _err(f"Already flashed into '{existing.flash_branch}'. Run 'flash out' first.")
138
+ raise typer.Exit(1)
139
+
140
+ # Resolve the target worktree
141
+ if name is None:
142
+ wt = fzf_pick_worktree(canonical_root)
143
+ if wt is None:
144
+ _err("No worktree selected.")
145
+ raise typer.Exit(1)
146
+ else:
147
+ wt = resolve_worktree(name, cwd=canonical_root)
148
+ if wt is None:
149
+ # Show available worktrees
150
+ worktrees = list_worktrees(cwd=canonical_root)
151
+ _err(f"Could not resolve '{name}' to a worktree.")
152
+ _info("Available worktrees:")
153
+ for w in worktrees:
154
+ if not w.is_bare and w.path != canonical_root:
155
+ typer.echo(f" {w.branch} ({w.path})")
156
+ raise typer.Exit(1)
157
+
158
+ # Don't flash into the canonical root itself
159
+ if wt.path == canonical_root:
160
+ _err("Cannot flash into the canonical checkout itself.")
161
+ raise typer.Exit(1)
162
+
163
+ try:
164
+ # Record current state
165
+ original_branch = get_current_branch(cwd=canonical_root)
166
+ original_head_sha = get_head_sha(cwd=canonical_root)
167
+
168
+ # Stash if dirty
169
+ stash_sha = None
170
+ if is_dirty(cwd=canonical_root):
171
+ _info("Stashing uncommitted changes...")
172
+ stash_sha = stash_changes(f"flash: before {wt.branch}", cwd=canonical_root)
173
+
174
+ # Create and checkout temp branch at worktree branch's HEAD
175
+ temp_branch = f"flash/{wt.branch}"
176
+ _info(f"Creating temp branch '{temp_branch}' at {wt.head[:8]}...")
177
+ create_and_checkout_temp_branch(temp_branch, wt.head, cwd=canonical_root)
178
+
179
+ # Copy uncommitted changes from worktree into canonical checkout
180
+ wt_synced = sync_changes("HEAD", wt.path, canonical_root)
181
+ if wt_synced:
182
+ _info(f"Copied {len(wt_synced)} uncommitted file(s) from worktree.")
183
+
184
+ # Ensure .flash/ is excluded from git
185
+ ensure_git_exclude(canonical_root)
186
+
187
+ # Write state — flash_base_sha is the worktree's HEAD (temp branch start)
188
+ state = FlashState(
189
+ original_branch=original_branch,
190
+ flash_branch=wt.branch,
191
+ temp_branch=temp_branch,
192
+ worktree_path=wt.path,
193
+ canonical_root=canonical_root,
194
+ original_head_sha=original_head_sha,
195
+ flash_base_sha=wt.head,
196
+ stash_sha=stash_sha,
197
+ started_at=now_iso(),
198
+ )
199
+ write_state(state)
200
+
201
+ _ok(f"Flashed into '{wt.branch}'. Run 'flash out' when done.")
202
+
203
+ except FlashError as e:
204
+ _err(str(e))
205
+ raise typer.Exit(1)
206
+
207
+
208
+ @app.command()
209
+ def out(
210
+ apply: bool = typer.Option(
211
+ False, "--apply", help="Apply changes to worktree before exiting"
212
+ ),
213
+ discard: bool = typer.Option(
214
+ False, "--discard", help="Discard changes made during flash"
215
+ ),
216
+ ) -> None:
217
+ """End flash session and restore original state. [magenta]\\[alias: o][/magenta]"""
218
+ try:
219
+ canonical_root = get_canonical_root()
220
+ except FlashError as e:
221
+ _err(str(e))
222
+ raise typer.Exit(1)
223
+
224
+ state = read_state(canonical_root)
225
+ if state is None:
226
+ _err("Not currently flashed in.")
227
+ raise typer.Exit(1)
228
+
229
+ try:
230
+ # Check for any changes (commits or unstaged)
231
+ commits = get_commits_since(state.flash_base_sha, cwd=canonical_root)
232
+ has_unstaged = is_dirty(cwd=canonical_root)
233
+ has_changes = bool(commits) or has_unstaged
234
+
235
+ if has_changes:
236
+ if apply and discard:
237
+ _err("Cannot use both --apply and --discard.")
238
+ raise typer.Exit(1)
239
+
240
+ if not apply and not discard:
241
+ # Interactive: prompt user
242
+ if sys.stdin.isatty():
243
+ _info("You have changes during this flash.")
244
+ if commits:
245
+ _info(f" {len(commits)} commit(s)")
246
+ if has_unstaged:
247
+ _info(" Uncommitted file changes")
248
+ choice = (
249
+ typer.prompt(
250
+ "[a]pply to worktree / [d]iscard",
251
+ default="d",
252
+ )
253
+ .strip()
254
+ .lower()
255
+ )
256
+ apply = choice in ("a", "apply")
257
+ discard = not apply
258
+ else:
259
+ _info("Non-interactive mode: discarding changes.")
260
+ discard = True
261
+
262
+ if apply:
263
+ _apply_to_worktree(state, canonical_root)
264
+
265
+ # Discard any local changes before switching branches
266
+ if is_dirty(cwd=canonical_root):
267
+ clean_working_tree(cwd=canonical_root)
268
+
269
+ # Restore original branch
270
+ _info(f"Checking out '{state.original_branch}'...")
271
+ checkout_branch(state.original_branch, cwd=canonical_root)
272
+
273
+ # Delete temp branch
274
+ delete_branch(state.temp_branch, cwd=canonical_root)
275
+
276
+ # Pop stash if we stashed
277
+ if state.stash_sha:
278
+ _info("Restoring stashed changes...")
279
+ if not pop_stash_by_sha(state.stash_sha, cwd=canonical_root):
280
+ _err(
281
+ f"Warning: Could not find stash with SHA {state.stash_sha}. "
282
+ f"Your changes may still be in the stash list."
283
+ )
284
+
285
+ # Clean up state
286
+ clear_state(canonical_root)
287
+
288
+ _ok(f"Back on '{state.original_branch}'.")
289
+
290
+ except FlashError as e:
291
+ _err(str(e))
292
+ _err("State file preserved for manual recovery.")
293
+ raise typer.Exit(1)
294
+
295
+
296
+ @app.command()
297
+ def status() -> None:
298
+ """Show current flash state. [magenta]\\[alias: st][/magenta]"""
299
+ try:
300
+ canonical_root = get_canonical_root()
301
+ except FlashError as e:
302
+ _err(str(e))
303
+ raise typer.Exit(1)
304
+
305
+ state = read_state(canonical_root)
306
+ if state is None:
307
+ typer.echo("Not flashed in.")
308
+ raise typer.Exit(0)
309
+
310
+ typer.echo(f"Flashed into: {state.flash_branch}")
311
+ typer.echo(f"Original branch: {state.original_branch}")
312
+ typer.echo(f"Temp branch: {state.temp_branch}")
313
+ typer.echo(f"Worktree: {state.worktree_path}")
314
+ typer.echo(f"Started: {state.started_at}")
315
+ if state.stash_sha:
316
+ typer.echo(f"Stash SHA: {state.stash_sha}")
317
+
318
+ # Show if there are current changes
319
+ commits = get_commits_since(state.flash_base_sha, cwd=canonical_root)
320
+ has_unstaged = is_dirty(cwd=canonical_root)
321
+ if commits:
322
+ _info(f"{len(commits)} new commit(s) since flash.")
323
+ if has_unstaged:
324
+ _info("Uncommitted file changes.")
325
+
326
+
327
+ @app.command("apply")
328
+ def apply_changes() -> None:
329
+ """Push current changes to the worktree without ending the flash. [magenta]\\[alias: a][/magenta]"""
330
+ try:
331
+ canonical_root = get_canonical_root()
332
+ except FlashError as e:
333
+ _err(str(e))
334
+ raise typer.Exit(1)
335
+
336
+ state = read_state(canonical_root)
337
+ if state is None:
338
+ _err("Not currently flashed in.")
339
+ raise typer.Exit(1)
340
+
341
+ try:
342
+ _apply_to_worktree(state, canonical_root)
343
+ except FlashError as e:
344
+ _err(str(e))
345
+ raise typer.Exit(1)
346
+
347
+
348
+ # Hidden short aliases
349
+ app.command("i", hidden=True)(into)
350
+ app.command("o", hidden=True)(out)
351
+ app.command("st", hidden=True)(status)
352
+ app.command("a", hidden=True)(apply_changes)
353
+
354
+
355
+ if __name__ == "__main__":
356
+ app()
@@ -0,0 +1,362 @@
1
+ """Git operations for flash: stash, checkout, diff, apply."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ class FlashError(Exception):
11
+ """Raised when a flash operation fails."""
12
+
13
+
14
+ @dataclass
15
+ class Worktree:
16
+ path: str
17
+ branch: str
18
+ head: str
19
+ is_bare: bool = False
20
+
21
+
22
+ def run_git(
23
+ *args: str,
24
+ cwd: str | Path | None = None,
25
+ check: bool = True,
26
+ capture: bool = True,
27
+ ) -> subprocess.CompletedProcess[str]:
28
+ """Run a git command and return the result."""
29
+ cmd = ["git", *args]
30
+ result = subprocess.run(
31
+ cmd,
32
+ cwd=cwd,
33
+ capture_output=capture,
34
+ text=True,
35
+ check=False,
36
+ )
37
+ if check and result.returncode != 0:
38
+ raise FlashError(f"git {' '.join(args)} failed:\n{result.stderr.strip()}")
39
+ return result
40
+
41
+
42
+ def get_canonical_root(cwd: str | Path | None = None) -> str:
43
+ """Get the canonical (non-worktree) repo root.
44
+
45
+ If we're in a worktree, resolve via .git commondir to find the main checkout.
46
+ """
47
+ cwd = cwd or Path.cwd()
48
+ # Get the git dir for current location
49
+ git_dir = run_git("rev-parse", "--git-dir", cwd=cwd).stdout.strip()
50
+ git_dir_path = Path(git_dir) if Path(git_dir).is_absolute() else Path(cwd) / git_dir
51
+ git_dir_path = git_dir_path.resolve()
52
+
53
+ # If this is a worktree, .git is a file pointing to the main repo's worktrees dir
54
+ # Use --git-common-dir to find the main repo's .git directory
55
+ common_dir = run_git("rev-parse", "--git-common-dir", cwd=cwd).stdout.strip()
56
+ common_dir_path = (
57
+ Path(common_dir) if Path(common_dir).is_absolute() else Path(cwd) / common_dir
58
+ )
59
+ common_dir_path = common_dir_path.resolve()
60
+
61
+ # The canonical root is the parent of the common .git dir
62
+ return str(common_dir_path.parent)
63
+
64
+
65
+ def get_current_branch(cwd: str | Path | None = None) -> str:
66
+ """Get the current branch name."""
67
+ result = run_git("symbolic-ref", "--short", "HEAD", cwd=cwd, check=False)
68
+ if result.returncode != 0:
69
+ # Detached HEAD — return the SHA
70
+ return run_git("rev-parse", "HEAD", cwd=cwd).stdout.strip()
71
+ return result.stdout.strip()
72
+
73
+
74
+ def get_head_sha(cwd: str | Path | None = None) -> str:
75
+ """Get the current HEAD SHA."""
76
+ return run_git("rev-parse", "HEAD", cwd=cwd).stdout.strip()
77
+
78
+
79
+ def is_dirty(cwd: str | Path | None = None) -> bool:
80
+ """Check if the working tree has uncommitted changes."""
81
+ result = run_git("status", "--porcelain", cwd=cwd)
82
+ return bool(result.stdout.strip())
83
+
84
+
85
+ def list_worktrees(cwd: str | Path | None = None) -> list[Worktree]:
86
+ """List all git worktrees."""
87
+ result = run_git("worktree", "list", "--porcelain", cwd=cwd)
88
+ worktrees: list[Worktree] = []
89
+ current: dict[str, str] = {}
90
+
91
+ for line in result.stdout.splitlines():
92
+ if not line.strip():
93
+ if current:
94
+ worktrees.append(
95
+ Worktree(
96
+ path=current["worktree"],
97
+ branch=current.get("branch", "").removeprefix("refs/heads/"),
98
+ head=current.get("HEAD", ""),
99
+ is_bare="bare" in current,
100
+ )
101
+ )
102
+ current = {}
103
+ continue
104
+ if line.startswith("worktree "):
105
+ current["worktree"] = line.split(" ", 1)[1]
106
+ elif line.startswith("HEAD "):
107
+ current["HEAD"] = line.split(" ", 1)[1]
108
+ elif line.startswith("branch "):
109
+ current["branch"] = line.split(" ", 1)[1]
110
+ elif line == "bare":
111
+ current["bare"] = "true"
112
+ elif line == "detached":
113
+ pass # skip detached marker
114
+
115
+ # Handle last entry
116
+ if current:
117
+ worktrees.append(
118
+ Worktree(
119
+ path=current["worktree"],
120
+ branch=current.get("branch", "").removeprefix("refs/heads/"),
121
+ head=current.get("HEAD", ""),
122
+ is_bare="bare" in current,
123
+ )
124
+ )
125
+
126
+ return worktrees
127
+
128
+
129
+ def resolve_worktree(name: str, cwd: str | Path | None = None) -> Worktree | None:
130
+ """Resolve a name to a worktree by matching directory name or branch name."""
131
+ worktrees = list_worktrees(cwd=cwd)
132
+
133
+ for wt in worktrees:
134
+ if wt.is_bare:
135
+ continue
136
+ dir_name = Path(wt.path).name
137
+ if dir_name == name or wt.branch == name:
138
+ return wt
139
+
140
+ # Partial match on directory name or branch name
141
+ matches = []
142
+ for wt in worktrees:
143
+ if wt.is_bare:
144
+ continue
145
+ dir_name = Path(wt.path).name
146
+ if name in dir_name or name in wt.branch:
147
+ matches.append(wt)
148
+
149
+ if len(matches) == 1:
150
+ return matches[0]
151
+
152
+ return None
153
+
154
+
155
+ def fzf_pick_worktree(canonical_root: str) -> Worktree | None:
156
+ """Use fzf to interactively pick a worktree."""
157
+ worktrees = list_worktrees(cwd=canonical_root)
158
+ # Filter out bare and the canonical root itself
159
+ candidates = [
160
+ wt for wt in worktrees if not wt.is_bare and wt.path != canonical_root
161
+ ]
162
+
163
+ if not candidates:
164
+ return None
165
+
166
+ # Format for fzf: "dir_name (branch) path"
167
+ lines = []
168
+ for wt in candidates:
169
+ dir_name = Path(wt.path).name
170
+ lines.append(f"{dir_name}\t{wt.branch}\t{wt.path}")
171
+
172
+ fzf_input = "\n".join(lines)
173
+
174
+ try:
175
+ result = subprocess.run(
176
+ [
177
+ "fzf",
178
+ "--header=Select a worktree",
179
+ "--delimiter=\t",
180
+ "--with-nth=1,2",
181
+ "--tabstop=4",
182
+ ],
183
+ input=fzf_input,
184
+ capture_output=True,
185
+ text=True,
186
+ check=False,
187
+ )
188
+ except FileNotFoundError:
189
+ raise FlashError("fzf not found. Install fzf or pass a worktree name.")
190
+
191
+ if result.returncode != 0:
192
+ return None # User cancelled
193
+
194
+ selected = result.stdout.strip()
195
+ if not selected:
196
+ return None
197
+
198
+ parts = selected.split("\t")
199
+ selected_path = parts[2] if len(parts) >= 3 else parts[0]
200
+
201
+ for wt in candidates:
202
+ if wt.path == selected_path:
203
+ return wt
204
+
205
+ return None
206
+
207
+
208
+ def stash_changes(message: str, cwd: str | Path | None = None) -> str | None:
209
+ """Stash changes and return the stash SHA, or None if nothing to stash."""
210
+ # Get stash list before
211
+ before = run_git("stash", "list", cwd=cwd).stdout.strip()
212
+
213
+ run_git("stash", "push", "-m", message, "--include-untracked", cwd=cwd)
214
+
215
+ # Get stash list after
216
+ after = run_git("stash", "list", cwd=cwd).stdout.strip()
217
+
218
+ if before == after:
219
+ return None # Nothing was stashed
220
+
221
+ # Get the SHA of the most recent stash
222
+ return run_git("rev-parse", "stash@{0}", cwd=cwd).stdout.strip()
223
+
224
+
225
+ def find_stash_by_sha(sha: str, cwd: str | Path | None = None) -> str | None:
226
+ """Find a stash entry by its SHA. Returns the stash ref (e.g., stash@{2})."""
227
+ result = run_git("stash", "list", "--format=%H", cwd=cwd)
228
+ for i, line in enumerate(result.stdout.strip().splitlines()):
229
+ if line.strip() == sha:
230
+ return f"stash@{{{i}}}"
231
+ return None
232
+
233
+
234
+ def pop_stash_by_sha(sha: str, cwd: str | Path | None = None) -> bool:
235
+ """Pop a specific stash entry identified by SHA. Returns True if successful."""
236
+ ref = find_stash_by_sha(sha, cwd=cwd)
237
+ if ref is None:
238
+ return False
239
+ run_git("stash", "pop", ref, cwd=cwd)
240
+ return True
241
+
242
+
243
+ def create_and_checkout_temp_branch(
244
+ temp_branch: str, target_sha: str, cwd: str | Path | None = None
245
+ ) -> None:
246
+ """Create a temporary branch at target_sha and check it out."""
247
+ # Delete if it already exists (leftover from a crashed session)
248
+ result = run_git("branch", "--list", temp_branch, cwd=cwd)
249
+ if result.stdout.strip():
250
+ run_git("branch", "-D", temp_branch, cwd=cwd)
251
+
252
+ run_git("checkout", "-b", temp_branch, target_sha, cwd=cwd)
253
+
254
+
255
+ def checkout_branch(branch: str, cwd: str | Path | None = None) -> None:
256
+ """Check out a branch."""
257
+ run_git("checkout", branch, cwd=cwd)
258
+
259
+
260
+ def delete_branch(branch: str, cwd: str | Path | None = None) -> None:
261
+ """Delete a branch."""
262
+ run_git("branch", "-D", branch, cwd=cwd, check=False)
263
+
264
+
265
+ def get_changed_files(ref: str, cwd: str | Path | None = None) -> list[str]:
266
+ """Get list of files changed between ref and working tree, including untracked."""
267
+ run_git("add", "-A", cwd=cwd)
268
+ result = run_git("diff", "--cached", "--name-only", ref, cwd=cwd)
269
+ run_git("reset", "HEAD", cwd=cwd, check=False)
270
+ return [f for f in result.stdout.strip().splitlines() if f.strip()]
271
+
272
+
273
+ def has_changes_against(ref: str, cwd: str | Path | None = None) -> bool:
274
+ """Check if there are any changes between ref and working tree."""
275
+ return bool(get_changed_files(ref, cwd=cwd))
276
+
277
+
278
+ def sync_changes(ref: str, src_dir: str | Path, dst_dir: str | Path) -> list[str]:
279
+ """Copy changed files from src_dir to dst_dir.
280
+
281
+ Compares src_dir's working tree against ref to find changed files,
282
+ then copies each one to dst_dir. This is more reliable than patching
283
+ because it handles repeated applies correctly — the target always gets
284
+ the current state of each changed file.
285
+
286
+ Returns list of file paths synced.
287
+ """
288
+ import shutil
289
+
290
+ src = Path(src_dir)
291
+ dst = Path(dst_dir)
292
+ changed = get_changed_files(ref, cwd=src_dir)
293
+
294
+ synced: list[str] = []
295
+ for filepath in changed:
296
+ src_file = src / filepath
297
+ dst_file = dst / filepath
298
+
299
+ if src_file.is_file():
300
+ dst_file.parent.mkdir(parents=True, exist_ok=True)
301
+ shutil.copy2(str(src_file), str(dst_file))
302
+ synced.append(filepath)
303
+ elif dst_file.is_file():
304
+ # File was deleted in source
305
+ dst_file.unlink()
306
+ synced.append(filepath)
307
+
308
+ return synced
309
+
310
+
311
+ def get_commits_since(base_sha: str, cwd: str | Path | None = None) -> list[str]:
312
+ """Get list of commit SHAs on current branch since base_sha (oldest first)."""
313
+ result = run_git("log", "--format=%H", "--reverse", f"{base_sha}..HEAD", cwd=cwd)
314
+ return [sha for sha in result.stdout.strip().splitlines() if sha.strip()]
315
+
316
+
317
+ def stash_create(cwd: str | Path | None = None) -> str | None:
318
+ """Create a stash commit without modifying working tree or stash list.
319
+
320
+ This is a read-only safety backup. Returns the stash SHA, or None
321
+ if the working tree is clean.
322
+ """
323
+ result = run_git("stash", "create", cwd=cwd)
324
+ sha = result.stdout.strip()
325
+ return sha if sha else None
326
+
327
+
328
+ def clean_working_tree(cwd: str | Path | None = None) -> None:
329
+ """Reset working tree to HEAD — discard all changes and untracked files."""
330
+ run_git("checkout", ".", cwd=cwd)
331
+ run_git("clean", "-fd", cwd=cwd)
332
+
333
+
334
+ def cherry_pick_to_worktree(commits: list[str], worktree_path: str | Path) -> int:
335
+ """Cherry-pick commits onto a clean worktree. Returns count picked.
336
+
337
+ The worktree MUST be clean before calling this — the caller is
338
+ responsible for saving and restoring any uncommitted state.
339
+ """
340
+ if not commits:
341
+ return 0
342
+ for sha in commits:
343
+ run_git("cherry-pick", sha, cwd=worktree_path)
344
+ return len(commits)
345
+
346
+
347
+ def ensure_git_exclude(canonical_root: str | Path) -> None:
348
+ """Add .flash/ to .git/info/exclude if not already there."""
349
+ exclude_file = Path(canonical_root) / ".git" / "info" / "exclude"
350
+ exclude_file.parent.mkdir(parents=True, exist_ok=True)
351
+
352
+ entry = ".flash/"
353
+ if exclude_file.exists():
354
+ content = exclude_file.read_text()
355
+ if entry in content:
356
+ return
357
+ if not content.endswith("\n"):
358
+ content += "\n"
359
+ content += entry + "\n"
360
+ exclude_file.write_text(content)
361
+ else:
362
+ exclude_file.write_text(entry + "\n")
@@ -0,0 +1,64 @@
1
+ """State file read/write/clear for flash sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ STATE_DIR = ".flash"
11
+ STATE_FILE = "state.json"
12
+
13
+
14
+ @dataclass
15
+ class FlashState:
16
+ original_branch: str
17
+ flash_branch: str
18
+ temp_branch: str
19
+ worktree_path: str
20
+ canonical_root: str
21
+ original_head_sha: str
22
+ flash_base_sha: str
23
+ started_at: str
24
+ stash_sha: str | None = None
25
+
26
+
27
+ def state_dir(canonical_root: str | Path) -> Path:
28
+ return Path(canonical_root) / STATE_DIR
29
+
30
+
31
+ def state_path(canonical_root: str | Path) -> Path:
32
+ return state_dir(canonical_root) / STATE_FILE
33
+
34
+
35
+ def read_state(canonical_root: str | Path) -> FlashState | None:
36
+ """Read the current flash state, or None if not flashed in."""
37
+ path = state_path(canonical_root)
38
+ if not path.exists():
39
+ return None
40
+ data = json.loads(path.read_text())
41
+ # Filter to known fields so old state files with removed fields still load
42
+ known = {f.name for f in FlashState.__dataclass_fields__.values()}
43
+ return FlashState(**{k: v for k, v in data.items() if k in known})
44
+
45
+
46
+ def write_state(state: FlashState) -> None:
47
+ """Write flash state to disk."""
48
+ directory = state_dir(state.canonical_root)
49
+ directory.mkdir(parents=True, exist_ok=True)
50
+ path = directory / STATE_FILE
51
+ path.write_text(json.dumps(asdict(state), indent=2) + "\n")
52
+
53
+
54
+ def clear_state(canonical_root: str | Path) -> None:
55
+ """Remove the .flash directory entirely."""
56
+ import shutil
57
+
58
+ directory = state_dir(canonical_root)
59
+ if directory.exists():
60
+ shutil.rmtree(directory)
61
+
62
+
63
+ def now_iso() -> str:
64
+ return datetime.now(UTC).isoformat()
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env bash
2
+ # Integration test for flash CLI.
3
+ # Creates a temp repo with a worktree, runs all commands, and verifies results.
4
+ set -euo pipefail
5
+
6
+ RED='\033[0;31m'
7
+ GREEN='\033[0;32m'
8
+ NC='\033[0m'
9
+ PASS=0
10
+ FAIL=0
11
+
12
+ assert_eq() {
13
+ local desc="$1" expected="$2" actual="$3"
14
+ if [ "$expected" = "$actual" ]; then
15
+ echo -e "${GREEN}PASS${NC}: $desc"
16
+ PASS=$((PASS + 1))
17
+ else
18
+ echo -e "${RED}FAIL${NC}: $desc"
19
+ echo " expected: $expected"
20
+ echo " actual: $actual"
21
+ FAIL=$((FAIL + 1))
22
+ fi
23
+ }
24
+
25
+ assert_contains() {
26
+ local desc="$1" needle="$2" haystack="$3"
27
+ if echo "$haystack" | grep -q "$needle"; then
28
+ echo -e "${GREEN}PASS${NC}: $desc"
29
+ PASS=$((PASS + 1))
30
+ else
31
+ echo -e "${RED}FAIL${NC}: $desc"
32
+ echo " expected to contain: $needle"
33
+ echo " actual: $haystack"
34
+ FAIL=$((FAIL + 1))
35
+ fi
36
+ }
37
+
38
+ assert_file_content() {
39
+ local desc="$1" file="$2" expected="$3"
40
+ local actual
41
+ actual=$(cat "$file")
42
+ assert_eq "$desc" "$expected" "$actual"
43
+ }
44
+
45
+ # --- Setup ---
46
+ TMPDIR=$(mktemp -d)
47
+ REPO="$TMPDIR/repo"
48
+ WT="$TMPDIR/worktree"
49
+ trap "rm -rf $TMPDIR" EXIT
50
+
51
+ mkdir "$REPO"
52
+ cd "$REPO"
53
+ git init -q
54
+ echo "original" > file.txt
55
+ git add . && git commit -q -m "initial"
56
+
57
+ git worktree add -q "$WT" -b test-branch
58
+ echo "committed-change" > "$WT/new-file.txt"
59
+ (cd "$WT" && git add . && git commit -q -m "worktree commit")
60
+ # Add uncommitted change in worktree
61
+ echo "uncommitted-change" > "$WT/uncommitted.txt"
62
+ echo "committed-change
63
+ extra-line" > "$WT/new-file.txt"
64
+
65
+ # Dirty the main checkout
66
+ echo "dirty" > "$REPO/dirty.txt"
67
+
68
+ echo ""
69
+ echo "=== Test 1: flash into ==="
70
+ cd "$REPO"
71
+ output=$(flash into test-branch 2>&1)
72
+ assert_contains "stash message" "Stashing" "$output"
73
+ assert_contains "flash message" "Flashed into" "$output"
74
+
75
+ echo ""
76
+ echo "=== Test 2: flash status ==="
77
+ output=$(flash status 2>&1)
78
+ assert_contains "shows branch" "test-branch" "$output"
79
+ assert_contains "shows original" "main" "$output"
80
+
81
+ echo ""
82
+ echo "=== Test 3: uncommitted changes copied in ==="
83
+ assert_file_content "uncommitted file present" "$REPO/uncommitted.txt" "uncommitted-change"
84
+ assert_file_content "modified file has worktree state" "$REPO/new-file.txt" "committed-change
85
+ extra-line"
86
+
87
+ echo ""
88
+ echo "=== Test 4: double flash refused ==="
89
+ output=$(flash into test-branch 2>&1 || true)
90
+ assert_contains "refuses double flash" "Already flashed" "$output"
91
+
92
+ echo ""
93
+ echo "=== Test 5: flash apply (unstaged files) ==="
94
+ echo "flash-fix" >> "$REPO/new-file.txt"
95
+ output=$(flash apply 2>&1)
96
+ assert_contains "apply syncs files" "Synced" "$output"
97
+ assert_file_content "worktree gets unstaged fix" "$WT/new-file.txt" "committed-change
98
+ extra-line
99
+ flash-fix"
100
+
101
+ echo ""
102
+ echo "=== Test 6: flash apply (commits) ==="
103
+ echo "committed-fix" > "$REPO/committed-file.txt"
104
+ (cd "$REPO" && git add . && git commit -q -m "fix during flash")
105
+ output=$(flash apply 2>&1)
106
+ assert_contains "cherry-pick message" "Cherry-picked 1 commit" "$output"
107
+ # The committed file should now be in the worktree's git history
108
+ wt_has_commit=$(cd "$WT" && git log --oneline | grep -c "fix during flash" || true)
109
+ assert_eq "commit landed in worktree" "1" "$wt_has_commit"
110
+
111
+ echo ""
112
+ echo "=== Test 7: flash out --apply (commits + unstaged) ==="
113
+ echo "second-committed" > "$REPO/second.txt"
114
+ (cd "$REPO" && git add . && git commit -q -m "second fix")
115
+ echo "loose-change" > "$REPO/loose.txt"
116
+ output=$(flash out --apply 2>&1)
117
+ assert_contains "cherry-pick on exit" "Cherry-picked 1 commit" "$output"
118
+ assert_contains "sync on exit" "Synced" "$output"
119
+ assert_contains "back on main" "Back on" "$output"
120
+
121
+ # Verify commit landed in worktree
122
+ wt_has_second=$(cd "$WT" && git log --oneline | grep -c "second fix" || true)
123
+ assert_eq "second commit in worktree" "1" "$wt_has_second"
124
+ # Verify unstaged file synced
125
+ assert_file_content "loose file in worktree" "$WT/loose.txt" "loose-change"
126
+
127
+ # Verify restoration
128
+ assert_eq "back on main" "main" "$(git branch --show-current)"
129
+ assert_file_content "stash restored" "$REPO/dirty.txt" "dirty"
130
+ assert_eq "no .flash dir" "false" "$([ -d "$REPO/.flash" ] && echo true || echo false)"
131
+ assert_eq "temp branch deleted" "" "$(git branch --list 'flash/*')"
132
+
133
+ echo ""
134
+ echo "=== Test 8: flash out --discard ==="
135
+ flash into test-branch >/dev/null 2>&1
136
+ echo "throwaway" >> "$REPO/new-file.txt"
137
+ wt_before=$(cd "$WT" && git log --oneline | wc -l | tr -d ' ')
138
+ flash out --discard >/dev/null 2>&1
139
+ wt_after=$(cd "$WT" && git log --oneline | wc -l | tr -d ' ')
140
+ assert_eq "discard doesn't touch worktree" "$wt_before" "$wt_after"
141
+
142
+ echo ""
143
+ echo "=== Test 9: flash from worktree directory ==="
144
+ cd "$WT"
145
+ flash into test-branch >/dev/null 2>&1
146
+ output=$(flash status 2>&1)
147
+ assert_contains "works from worktree" "Flashed into: test-branch" "$output"
148
+ canonical_branch=$(cd "$REPO" && git branch --show-current)
149
+ assert_eq "canonical checkout switched" "flash/test-branch" "$canonical_branch"
150
+ flash out --discard >/dev/null 2>&1
151
+
152
+ echo ""
153
+ echo "================================"
154
+ echo -e "Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
155
+ [ "$FAIL" -eq 0 ] && exit 0 || exit 1