Skip to content

Commit adc91f9

Browse files
committed
feat: worktree hooks, incl default mise hook
1 parent 5dc03eb commit adc91f9

8 files changed

Lines changed: 323 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v4
17+
18+
- name: Setup mise
19+
uses: jdx/mise-action@v2
20+
with:
21+
install: true
22+
23+
- name: Install dependencies
24+
run: mix deps.get
25+
26+
- name: Run tests
27+
run: mix test

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
git_work
1+
/git_work
22
/_build
33
/cover
44
/deps
@@ -9,4 +9,4 @@ erl_crash.dump
99
*.beam
1010
/config/*.secret.exs
1111
.elixir_ls/
12-
dist/
12+
dist/

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,8 @@ mix escript.build
115115
Supports `--dry-run` and `--force`.
116116
- **Shell integration** via `eval "$(git-work --shell-hook)"` which defines a
117117
`gw` shell function that wraps the binary and `cd`s into path output.
118+
119+
## Agent Instructions
120+
121+
- Always run the test suite yourself to verify new features work.
122+
- Always fix lint warnings and errors before finishing.

lib/git_work/commands/checkout.ex

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule GitWork.Commands.Checkout do
44
Supports fuzzy matching against existing worktrees.
55
"""
66

7-
alias GitWork.{Git, Project, Fuzzy}
7+
alias GitWork.{Git, Project, Fuzzy, Hooks}
88

99
def help do
1010
"""
@@ -111,27 +111,69 @@ defmodule GitWork.Commands.Checkout do
111111
{:ok, _} ->
112112
# Local branch exists
113113
case Git.cmd(["worktree", "add", worktree_dir, branch], cd: bare_dir) do
114-
{:ok, _} -> {:ok, worktree_dir}
115-
{:error, msg} -> {:error, "worktree add failed: #{msg}"}
114+
{:ok, _} ->
115+
run_hooks(root, worktree_dir, branch)
116+
117+
{:error, msg} ->
118+
{:error, "worktree add failed: #{msg}"}
116119
end
117120

118121
{:error, _} ->
119122
# Brand new branch
120123
case Git.cmd(["worktree", "add", "-b", branch, worktree_dir], cd: bare_dir) do
121-
{:ok, _} -> {:ok, worktree_dir}
122-
{:error, msg} -> {:error, "worktree add failed: #{msg}"}
124+
{:ok, _} ->
125+
run_hooks(root, worktree_dir, branch)
126+
127+
{:error, msg} ->
128+
{:error, "worktree add failed: #{msg}"}
123129
end
124130
end
125131

126132
{:ok, _} ->
127133
# Remote branch exists — track it
128134
case Git.cmd(["worktree", "add", worktree_dir, branch], cd: bare_dir) do
129-
{:ok, _} -> {:ok, worktree_dir}
130-
{:error, msg} -> {:error, "worktree add failed: #{msg}"}
135+
{:ok, _} ->
136+
run_hooks(root, worktree_dir, branch)
137+
138+
{:error, msg} ->
139+
{:error, "worktree add failed: #{msg}"}
131140
end
132141

133142
{:error, msg} ->
134143
{:error, "failed to check remote branches: #{msg}"}
135144
end
136145
end
146+
147+
defp run_hooks(root, worktree_dir, branch) do
148+
ctx = %{
149+
root: root,
150+
worktree_dir: worktree_dir,
151+
branch: branch,
152+
source_worktree: File.cwd!()
153+
}
154+
155+
case Hooks.run(:post_worktree_create, ctx) do
156+
:ok ->
157+
{:ok, worktree_dir}
158+
159+
{:error, msg} ->
160+
rollback_worktree(root, worktree_dir, branch, msg)
161+
end
162+
end
163+
164+
defp rollback_worktree(root, worktree_dir, branch, reason) do
165+
bare_dir = Project.bare_path(root)
166+
167+
case Git.cmd(["worktree", "remove", worktree_dir], cd: bare_dir) do
168+
{:ok, _} -> :ok
169+
{:error, msg} -> IO.write(:stderr, "rollback: worktree remove failed: #{msg}\n")
170+
end
171+
172+
case Git.cmd(["branch", "-D", branch], cd: bare_dir) do
173+
{:ok, _} -> :ok
174+
{:error, msg} -> IO.write(:stderr, "rollback: branch delete failed: #{msg}\n")
175+
end
176+
177+
{:error, reason}
178+
end
137179
end

lib/git_work/hooks.ex

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
defmodule GitWork.Hooks do
2+
@moduledoc """
3+
Hook runner for worktree lifecycle events.
4+
"""
5+
6+
alias GitWork.{Git, Project}
7+
8+
def run(:post_worktree_create, ctx) do
9+
run_mise_hook(ctx)
10+
end
11+
12+
def run(:post_checkout, _ctx), do: :ok
13+
14+
def run(_event, _ctx), do: :ok
15+
16+
defp run_mise_hook(%{root: root, worktree_dir: worktree_dir} = ctx) do
17+
case System.find_executable("mise") do
18+
nil ->
19+
IO.write(:stderr, "hook: mise not found; skipping trust and task\n")
20+
:ok
21+
22+
_path ->
23+
trust_enabled = mise_trust_enabled?(root)
24+
task = mise_task(root)
25+
26+
with :ok <- maybe_trust_mise(trust_enabled, ctx),
27+
:ok <- maybe_run_task(task, worktree_dir) do
28+
:ok
29+
end
30+
end
31+
end
32+
33+
defp maybe_trust_mise(false, _ctx), do: :ok
34+
35+
defp maybe_trust_mise(true, %{source_worktree: nil}), do: :ok
36+
37+
defp maybe_trust_mise(true, %{source_worktree: source, worktree_dir: worktree_dir}) do
38+
case cmd("mise", ["trust", "--show"], cd: source) do
39+
{:ok, output} when output != "" ->
40+
case cmd("mise", ["trust"], cd: worktree_dir) do
41+
{:ok, _} -> :ok
42+
{:error, msg} -> {:error, "mise trust failed: #{msg}"}
43+
end
44+
45+
{:ok, _} ->
46+
:ok
47+
48+
{:error, msg} ->
49+
{:error, "mise trust --show failed: #{msg}"}
50+
end
51+
end
52+
53+
defp maybe_run_task(nil, _worktree_dir), do: :ok
54+
55+
defp maybe_run_task(task, worktree_dir) do
56+
case cmd("mise", ["run", task], cd: worktree_dir) do
57+
{:ok, _} -> :ok
58+
{:error, msg} -> {:error, "mise run #{task} failed: #{msg}"}
59+
end
60+
end
61+
62+
defp mise_trust_enabled?(root) do
63+
case config_get_bool(root, "git-work.hooks.mise.trust") do
64+
{:ok, value} -> value
65+
:unset -> true
66+
end
67+
end
68+
69+
defp mise_task(root) do
70+
case config_get_string(root, "git-work.hooks.mise.task") do
71+
{:ok, ""} -> nil
72+
{:ok, value} -> value
73+
:unset -> "worktree:setup"
74+
end
75+
end
76+
77+
defp config_get_bool(root, key) do
78+
bare_dir = Project.bare_path(root)
79+
80+
case Git.cmd(["config", "--get", "--bool", key], cd: bare_dir) do
81+
{:ok, "true"} -> {:ok, true}
82+
{:ok, "false"} -> {:ok, false}
83+
{:ok, value} -> {:ok, value == "true"}
84+
{:error, _} -> :unset
85+
end
86+
end
87+
88+
defp config_get_string(root, key) do
89+
bare_dir = Project.bare_path(root)
90+
91+
case Git.cmd(["config", "--get", key], cd: bare_dir) do
92+
{:ok, value} -> {:ok, value}
93+
{:error, _} -> :unset
94+
end
95+
end
96+
97+
defp cmd(bin, args, opts) do
98+
cmd_opts = [stderr_to_stdout: true]
99+
100+
cmd_opts =
101+
case Keyword.get(opts, :cd) do
102+
nil -> cmd_opts
103+
dir -> Keyword.put(cmd_opts, :cd, dir)
104+
end
105+
106+
case System.cmd(bin, args, cmd_opts) do
107+
{output, 0} -> {:ok, String.trim(output)}
108+
{output, _code} -> {:error, String.trim(output)}
109+
end
110+
end
111+
end

test/git_work/commands/checkout_test.exs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ defmodule GitWork.Commands.CheckoutTest do
9696
project = Path.join(tmp, "project")
9797
{:ok, _} = GitWork.Commands.Clone.run([origin, project])
9898

99+
{_, 0} =
100+
System.cmd("git", ["config", "git-work.hooks.mise.task", ""],
101+
cd: Path.join(project, ".bare")
102+
)
103+
99104
# Create a branch on the remote
100105
GitWork.TestHelper.create_remote_branch(origin, "feature-remote")
101106

@@ -114,6 +119,11 @@ defmodule GitWork.Commands.CheckoutTest do
114119
project = Path.join(tmp, "project")
115120
{:ok, _} = GitWork.Commands.Clone.run([origin, project])
116121

122+
{_, 0} =
123+
System.cmd("git", ["config", "git-work.hooks.mise.task", ""],
124+
cd: Path.join(project, ".bare")
125+
)
126+
117127
# Create a branch on the remote
118128
GitWork.TestHelper.create_remote_branch(origin, "feature-remote")
119129

@@ -132,4 +142,59 @@ defmodule GitWork.Commands.CheckoutTest do
132142
{output, 0} = System.cmd("git", ["worktree", "list"], cd: Path.join(project, ".bare"))
133143
assert output =~ "feature-remote"
134144
end
145+
146+
test "post worktree hook runs on -b and can modify worktree", %{tmp: tmp} do
147+
project = GitWork.TestHelper.create_gw_project(tmp)
148+
bare = Path.join(project, ".bare")
149+
150+
GitWork.TestHelper.write_hook_script(tmp)
151+
GitWork.TestHelper.prepend_path(tmp)
152+
153+
File.write!(Path.join([project, "main", ".trusted"]), "ok")
154+
155+
{_, 0} = System.cmd("git", ["config", "git-work.hooks.mise.task", "hook-task"], cd: bare)
156+
157+
File.cd!(Path.join(project, "main"))
158+
159+
assert {:ok, path} = Checkout.run(["-b", "feature-hook"])
160+
assert File.regular?(Path.join(path, "hook-ran"))
161+
assert File.regular?(Path.join(path, ".trusted"))
162+
end
163+
164+
test "existing worktree checkout does not run hook", %{tmp: tmp} do
165+
project = GitWork.TestHelper.create_gw_project(tmp)
166+
bare = Path.join(project, ".bare")
167+
168+
GitWork.TestHelper.write_hook_script(tmp)
169+
GitWork.TestHelper.prepend_path(tmp)
170+
171+
{_, 0} = System.cmd("git", ["config", "git-work.hooks.mise.task", "hook-task"], cd: bare)
172+
173+
File.cd!(Path.join(project, "main"))
174+
175+
assert {:ok, _} = Checkout.run(["-b", "feature-existing"])
176+
File.rm!(Path.join([project, "feature-existing", "hook-ran"]))
177+
178+
assert {:ok, path} = Checkout.run(["feature-existing"])
179+
refute File.regular?(Path.join(path, "hook-ran"))
180+
end
181+
182+
test "post worktree hook failure triggers rollback", %{tmp: tmp} do
183+
project = GitWork.TestHelper.create_gw_project(tmp)
184+
bare = Path.join(project, ".bare")
185+
186+
GitWork.TestHelper.write_hook_script(tmp)
187+
GitWork.TestHelper.prepend_path(tmp)
188+
189+
{_, 0} = System.cmd("git", ["config", "git-work.hooks.mise.task", "hook-fail"], cd: bare)
190+
191+
File.cd!(Path.join(project, "main"))
192+
193+
assert {:error, msg} = Checkout.run(["-b", "feature-fail"])
194+
assert msg =~ "mise run"
195+
refute File.dir?(Path.join(project, "feature-fail"))
196+
197+
{output, 0} = System.cmd("git", ["branch", "--list", "feature-fail"], cd: bare)
198+
assert output == ""
199+
end
135200
end

test/git_work/commands/sync_test.exs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@ defmodule GitWork.Commands.SyncTest do
1717
end
1818

1919
test "prunes worktree whose remote branch was deleted", %{tmp: tmp} do
20-
origin = GitWork.TestHelper.create_origin_repo(tmp)
21-
project = Path.join(tmp, "project")
22-
{:ok, _} = GitWork.Commands.Clone.run([origin, project])
20+
project = GitWork.TestHelper.create_gw_project(tmp)
21+
origin = Path.join(tmp, "origin.git")
2322

2423
# Create remote branch, fetch, checkout
2524
GitWork.TestHelper.create_remote_branch(origin, "feature-stale")
@@ -43,9 +42,8 @@ defmodule GitWork.Commands.SyncTest do
4342
end
4443

4544
test "--dry-run shows candidates without removing", %{tmp: tmp} do
46-
origin = GitWork.TestHelper.create_origin_repo(tmp)
47-
project = Path.join(tmp, "project")
48-
{:ok, _} = GitWork.Commands.Clone.run([origin, project])
45+
project = GitWork.TestHelper.create_gw_project(tmp)
46+
origin = Path.join(tmp, "origin.git")
4947

5048
GitWork.TestHelper.create_remote_branch(origin, "feature-dry")
5149
System.cmd("git", ["fetch", "--all"], cd: Path.join(project, ".bare"))
@@ -65,9 +63,7 @@ defmodule GitWork.Commands.SyncTest do
6563
end
6664

6765
test "never prunes HEAD branch", %{tmp: tmp} do
68-
origin = GitWork.TestHelper.create_origin_repo(tmp)
69-
project = Path.join(tmp, "project")
70-
{:ok, _} = GitWork.Commands.Clone.run([origin, project])
66+
project = GitWork.TestHelper.create_gw_project(tmp)
7167

7268
File.cd!(Path.join(project, "main"))
7369

0 commit comments

Comments
 (0)