1package cli
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "arche/internal/diff"
11 "arche/internal/wc"
12
13 "github.com/spf13/cobra"
14)
15
16const archehooksDir = ".archehooks"
17
18type hookEntry struct {
19 name string
20 relPath string
21 phase string
22}
23
24func listArcheHooks(repoRoot string) ([]hookEntry, error) {
25 var entries []hookEntry
26 for _, phase := range []string{"pre-snap", "post-snap"} {
27 dir := filepath.Join(repoRoot, archehooksDir, phase)
28 infos, err := os.ReadDir(dir)
29 if os.IsNotExist(err) {
30 continue
31 }
32 if err != nil {
33 return nil, fmt.Errorf("read %s: %w", dir, err)
34 }
35 for _, info := range infos {
36 if info.IsDir() {
37 continue
38 }
39 rel := archehooksDir + "/" + phase + "/" + info.Name()
40 entries = append(entries, hookEntry{
41 name: info.Name(),
42 relPath: rel,
43 phase: phase,
44 })
45 }
46 }
47 return entries, nil
48}
49
50func isInstalled(commands []string, hookRelPath string) bool {
51 for _, cmd := range commands {
52 if cmd == hookRelPath {
53 return true
54 }
55 }
56 return false
57}
58
59var hooksCmd = &cobra.Command{
60 Use: "hooks",
61 Short: "Manage versioned snap hooks from .archehooks/",
62 Long: `Manage client-side snap hooks.
63
64Hooks in .archehooks/pre-snap/ and .archehooks/post-snap/ are versioned with
65the repository but are NEVER executed automatically. Installation is always
66an explicit opt-in via 'arche hooks install'.
67
68Installed hooks are stored in .arche/config.toml (local, never synced).`,
69}
70
71var hooksListCmd = &cobra.Command{
72 Use: "list",
73 Short: "Show hooks defined in .archehooks/ and their installation status",
74 RunE: func(cmd *cobra.Command, args []string) error {
75 r := openRepo()
76 defer r.Close()
77
78 entries, err := listArcheHooks(r.Root)
79 if err != nil {
80 return err
81 }
82 if len(entries) == 0 {
83 fmt.Println("No hooks found in .archehooks/")
84 return nil
85 }
86
87 cfg := r.Cfg
88 for _, e := range entries {
89 var installed string
90 switch e.phase {
91 case "pre-snap":
92 if isInstalled(cfg.Hooks.PreSnap, e.relPath) {
93 installed = "installed"
94 } else {
95 installed = "not installed"
96 }
97 case "post-snap":
98 if isInstalled(cfg.Hooks.PostSnap, e.relPath) {
99 installed = "installed"
100 } else {
101 installed = "not installed"
102 }
103 }
104 fmt.Printf("[%s] %-30s %s\n", e.phase, e.name, installed)
105 }
106 return nil
107 },
108}
109
110var hooksInstallCmd = &cobra.Command{
111 Use: "install [name]",
112 Short: "Opt-in install of a repo hook after reviewing its content",
113 Long: `Show the full content of the hook script and prompt for confirmation
114before adding it to .arche/config.toml.
115
116If no name is given, prompts for each not-yet-installed hook in .archehooks/.`,
117 RunE: func(cmd *cobra.Command, args []string) error {
118 r := openRepo()
119 defer r.Close()
120
121 all, err := listArcheHooks(r.Root)
122 if err != nil {
123 return err
124 }
125
126 var candidates []hookEntry
127 if len(args) > 0 {
128 name := args[0]
129 for _, e := range all {
130 if e.name == name || strings.TrimSuffix(e.name, filepath.Ext(e.name)) == name {
131 candidates = append(candidates, e)
132 }
133 }
134 if len(candidates) == 0 {
135 return fmt.Errorf("hook %q not found in .archehooks/", name)
136 }
137 } else {
138 cfg := r.Cfg
139 for _, e := range all {
140 var installed bool
141 switch e.phase {
142 case "pre-snap":
143 installed = isInstalled(cfg.Hooks.PreSnap, e.relPath)
144 case "post-snap":
145 installed = isInstalled(cfg.Hooks.PostSnap, e.relPath)
146 }
147 if !installed {
148 candidates = append(candidates, e)
149 }
150 }
151 if len(candidates) == 0 {
152 fmt.Println("All available hooks are already installed.")
153 return nil
154 }
155 }
156
157 sc := bufio.NewScanner(os.Stdin)
158 changed := false
159
160 for _, e := range candidates {
161 absPath := filepath.Join(r.Root, filepath.FromSlash(e.relPath))
162 content, err := os.ReadFile(absPath)
163 if err != nil {
164 return fmt.Errorf("read hook %s: %w", e.relPath, err)
165 }
166
167 fmt.Printf("\n--- Hook: %s [%s] ---\n", e.name, e.phase)
168 fmt.Println(string(content))
169 fmt.Printf("--- End of hook ---\n")
170 fmt.Printf("Install this hook in %s? [y/N] ", e.phase)
171
172 if !sc.Scan() {
173 break
174 }
175 answer := strings.TrimSpace(strings.ToLower(sc.Text()))
176 if answer != "y" && answer != "yes" {
177 fmt.Printf("Skipped %s\n", e.name)
178 continue
179 }
180
181 switch e.phase {
182 case "pre-snap":
183 r.Cfg.Hooks.PreSnap = append(r.Cfg.Hooks.PreSnap, e.relPath)
184 case "post-snap":
185 r.Cfg.Hooks.PostSnap = append(r.Cfg.Hooks.PostSnap, e.relPath)
186 }
187 fmt.Printf("Installed %s in %s\n", e.name, e.phase)
188 changed = true
189 }
190
191 if changed {
192 if err := r.SaveConfig(); err != nil {
193 return fmt.Errorf("save config: %w", err)
194 }
195 }
196 return nil
197 },
198}
199
200var hooksDiffCmd = &cobra.Command{
201 Use: "diff",
202 Short: "Diff committed .archehooks/ scripts against working-copy versions",
203 Long: `For each installed command in .arche/config.toml that references an
204.archehooks/ script, show a unified diff between the version committed in HEAD
205and the file currently on disk.
206
207Use this after pulling changes from collaborators to review exactly what changed
208in hook scripts before running them.`,
209 RunE: func(cmd *cobra.Command, args []string) error {
210 r := openRepo()
211 defer r.Close()
212
213 head, _, err := r.HeadCommit()
214 if err != nil {
215 return fmt.Errorf("head commit: %w", err)
216 }
217
218 committedFiles := make(map[string][32]byte)
219 if err := diff.FlattenTree(r, head.TreeID, committedFiles); err != nil {
220 return fmt.Errorf("flatten head tree: %w", err)
221 }
222
223 cfg := r.Cfg
224 type phaseCmd struct {
225 phase string
226 cmd string
227 }
228 var allCmds []phaseCmd
229 for _, c := range cfg.Hooks.PreSnap {
230 allCmds = append(allCmds, phaseCmd{"pre-snap", c})
231 }
232 for _, c := range cfg.Hooks.PostSnap {
233 allCmds = append(allCmds, phaseCmd{"post-snap", c})
234 }
235
236 hasArcheHooks := false
237 hasDiff := false
238 for _, entry := range allCmds {
239 if !strings.HasPrefix(entry.cmd, archehooksDir+"/") {
240 continue
241 }
242 hasArcheHooks = true
243
244 var committedContent string
245 if blobID, ok := committedFiles[entry.cmd]; ok {
246 if data, readErr := r.ReadBlob(blobID); readErr == nil {
247 committedContent = string(data)
248 }
249 }
250
251 absPath := filepath.Join(r.Root, filepath.FromSlash(entry.cmd))
252 diskData, diskErr := os.ReadFile(absPath)
253 var diskContent string
254 if diskErr == nil {
255 diskContent = string(diskData)
256 } else if !os.IsNotExist(diskErr) {
257 fmt.Fprintf(os.Stderr, "warning: read %s: %v\n", entry.cmd, diskErr)
258 }
259
260 patch := diff.UnifiedDiff(entry.cmd, committedContent, diskContent)
261 if patch == "" {
262 continue
263 }
264 fmt.Printf("=== [%s] %s ===\n", entry.phase, entry.cmd)
265 fmt.Print(patch)
266 hasDiff = true
267 }
268
269 if !hasArcheHooks {
270 fmt.Println("No installed .archehooks/ scripts found in config.")
271 fmt.Println("Run 'arche hooks list' to see available hooks.")
272 } else if !hasDiff {
273 fmt.Println("No changes to installed .archehooks/ scripts (all match committed version).")
274 }
275 return nil
276 },
277}
278
279var hooksRunCmd = &cobra.Command{
280 Use: "run <pre-snap|post-snap>",
281 Short: "Manually run pre-snap or post-snap hooks",
282 Args: cobra.ExactArgs(1),
283 RunE: func(cmd *cobra.Command, args []string) error {
284 r := openRepo()
285 defer r.Close()
286
287 var hooks []string
288 switch args[0] {
289 case "pre-snap":
290 hooks = r.Cfg.Hooks.PreSnap
291 case "post-snap":
292 hooks = r.Cfg.Hooks.PostSnap
293 default:
294 return fmt.Errorf("unknown hook phase %q (use pre-snap or post-snap)", args[0])
295 }
296
297 if len(hooks) == 0 {
298 fmt.Printf("No %s hooks configured.\n", args[0])
299 return nil
300 }
301
302 return wc.RunHooksSequential(r.Root, args[0], hooks)
303 },
304}
305
306func init() {
307 hooksCmd.AddCommand(hooksListCmd, hooksInstallCmd, hooksDiffCmd, hooksRunCmd)
308}