arche / internal/cli/cmd_hooks.go

commit 154431fd
  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}