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.
- worktree_flash-0.1.0/.github/workflows/publish.yml +21 -0
- worktree_flash-0.1.0/.gitignore +5 -0
- worktree_flash-0.1.0/LICENSE +21 -0
- worktree_flash-0.1.0/PKG-INFO +131 -0
- worktree_flash-0.1.0/README.md +112 -0
- worktree_flash-0.1.0/assets/cli.png +0 -0
- worktree_flash-0.1.0/assets/demo.gif +0 -0
- worktree_flash-0.1.0/assets/demo.tape +38 -0
- worktree_flash-0.1.0/pyproject.toml +33 -0
- worktree_flash-0.1.0/src/flash/__init__.py +0 -0
- worktree_flash-0.1.0/src/flash/cli.py +356 -0
- worktree_flash-0.1.0/src/flash/core.py +362 -0
- worktree_flash-0.1.0/src/flash/state.py +64 -0
- worktree_flash-0.1.0/test_flash.sh +155 -0
|
@@ -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,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
|