Skip to content

feat: snapshot pipeline from zsh hook to APFS clonefile store - #1

Open
mickamy wants to merge 8 commits into
mainfrom
feat/clone
Open

feat: snapshot pipeline from zsh hook to APFS clonefile store#1
mickamy wants to merge 8 commits into
mainfrom
feat/clone

Conversation

@mickamy

@mickamy mickamy commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the "capture" half of mulligan: a zsh preexec hook that snapshots files via APFS clonefile(2) before destructive commands run.

  • internal/clonefile: clonefile(2) wrapper with APFS same-volume detection
  • internal/store: generation store with two-phase commit (crash-safe; orphans are left for prune)
  • internal/detect: destructive command detection (rm, git clean/reset --hard/checkout --/restore, sed -i, mv) with path extraction; falls back to opaque on shell expansion ($VAR, globs)
  • internal/scope: three-tier scope decision (named paths > whole cwd if small > git-unprotected files in large repos)
  • internal/snap: orchestration; per-path failures never block the user's command
  • CLI: mulligan snap --cmd and mulligan hook zsh; install with eval "$(mulligan hook zsh)"

CI now runs on macos-latest since clonefile(2) is darwin-only.

Test plan

  • make test (all packages, including a CLI-level integration test against a real APFS volume)
  • Manual smoke test: snap before rm -rf build, snapshot survives with correct content and metadata

The restore half (restore, diff, the no-arg undo flow) comes in a follow-up PR.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the core pipeline for mulligan, a utility that automatically snapshots files before destructive shell commands run. It introduces packages for parsing commands (detect), determining snapshot scopes (scope), managing on-disk generations (store), orchestrating the snapshotting process (snap), and utilizing macOS clonefile(2) for zero-copy APFS cloning (clonefile). Feedback on the changes highlights two critical compilation issues in the clonefile package: a type mismatch on Darwin when converting srcFS.Fstypename to a byte slice, and a build failure on non-Darwin platforms due to the lack of fallback stub implementations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +37 to +39
if unix.ByteSliceToString(srcFS.Fstypename[:]) != "apfs" {
return false, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

  • Severity: High
  • Issue: srcFS.Fstypename is of type [16]int8 on Darwin, so srcFS.Fstypename[:] produces a slice of type []int8. Passing this to unix.ByteSliceToString, which expects []byte, will cause a compilation error.
  • Recommendation: Convert the int8 array to a byte slice before calling unix.ByteSliceToString.
	var fstypeBuf [16]byte
	for i, b := range srcFS.Fstypename {
		fstypeBuf[i] = byte(b)
	}
	if unix.ByteSliceToString(fstypeBuf[:]) != "apfs" {
		return false, nil
	}
References
  1. Issues must be output strictly in the format: - Severity: ... - Issue: ... - Recommendation: ... (link)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a false positive. Fstypename is [16]int8 in the standard library's syscall.Statfs_t, but this code uses golang.org/x/sys/unix.Statfs_t, where it is [16]byte on Darwin:

$ go doc golang.org/x/sys/unix.Statfs_t | grep Fstypename
	Fstypename  [16]byte

The package compiles and its tests pass on macOS (see the CI run on macos-latest).

@@ -0,0 +1,42 @@
//go:build darwin

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

  • Severity: Medium
  • Issue: The clonefile package only contains files with the //go:build darwin constraint. When compiling the project on non-Darwin platforms (such as Linux or Windows), this will cause a "build constraints exclude all Go files" compilation error, which prevents the CLI from building even if the platform is not supported.
  • Recommendation: Add a fallback file (e.g., clonefile_fallback.go) with //go:build !darwin that provides stub implementations of Clone and Supported returning appropriate errors or false.
References
  1. Issues must be output strictly in the format: - Severity: ... - Issue: ... - Recommendation: ... (link)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional: mulligan is macOS-only for now because the snapshot mechanism is APFS clonefile(2) itself, so a non-Darwin build has nothing meaningful to do. Release builds (.goreleaser.yaml) and CI both target Darwin only.

A Linux port would be backed by reflink (copy_file_range on btrfs/XFS), at which point this package gets a real _linux.go implementation rather than an error-returning stub.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements the “capture” half of mulligan: detecting destructive shell commands, deciding what to snapshot, and cloning targets into a per-project on-disk generation store via APFS clonefile(2) (with zsh preexec hook + CLI wiring).

Changes:

  • Added destructive-command detection (internal/detect) and a 3-tier scope decision engine (internal/scope) to select snapshot targets.
  • Implemented a crash-tolerant generation store with atomic metadata commit (internal/store) and orchestration to clone selected paths into the store (internal/snap).
  • Added a darwin-only clonefile(2) wrapper (internal/clonefile), CLI commands (snap, hook zsh), and moved CI to macos-latest.

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
internal/store/store.go New on-disk store layout: per-project directory + numbered generations + atomic meta.json commit.
internal/store/store_test.go Unit tests for default root selection, project naming, generation allocation/visibility, and commit atomicity.
internal/snap/snap.go New snapshot orchestration pipeline: detect → scope decide → APFS support filter → clone → commit metadata.
internal/snap/snap_test.go Integration-style tests validating snapshot survival and scope behaviors.
internal/scope/scope.go Scope decision logic (paths → cwd-if-small → git-unprotected-if-large) with path resolution utilities.
internal/scope/scope_test.go Tests for the 3-tier scope strategy, including git status parsing behavior.
internal/hook/hook.go Zsh preexec hook snippet generator for invoking mulligan snap --cmd "$3".
internal/detect/lex.go Command-line segmentation/tokenization for POSIX-ish quoting and compound commands.
internal/detect/detect.go Destructive command detection and path extraction (rm/mv/git/sed) with “opaque target” fallback.
internal/detect/detect_test.go Table tests for detection behavior across commands, wrappers, redirects, and compounds.
internal/clonefile/clonefile.go Darwin-only clonefile(2) wrapper + same-APFS-volume support check.
internal/clonefile/clonefile_test.go Tests for file/dir/symlink cloning and APFS support detection.
internal/cli/cli.go Implements snap and hook zsh subcommands with a timeout for preexec usage.
internal/cli/cli_test.go Extends CLI tests, including an end-to-end destructive snap flow validating store output.
go.mod Adds dependency on golang.org/x/sys for darwin clonefile syscalls.
go.sum Adds checksums for golang.org/x/sys.
.golangci.yaml Adjusts lint configuration (wrapcheck ignore globs; disables gosec in tests).
.github/workflows/ci.yaml Runs lint/test/build on macos-latest to support darwin-only clonefile behavior.

Comment thread internal/snap/snap.go
Comment on lines +95 to +99
// Snapshots mirror the absolute path under the data dir, so no
// path needs to be relative to the project root.
dst := filepath.Join(gen.DataDir(), p)

if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a false positive: Go's filepath.Join does not discard earlier elements when a later element is absolute (that is Python's os.path.join behavior). It concatenates all elements and runs Clean:

filepath.Join("/store/data", "/Users/x/build")
// => "/store/data/Users/x/build"

The absolute-path-mirror layout under gen.DataDir() is intentional and is verified by TestSnap_NamedPath and the CLI integration test TestRun_SnapDestructive, which assert the snapshot content exists under the store after the original is removed. CI on macos-latest is green.

Comment thread internal/snap/snap.go
Comment on lines +3 to +14
import (
"context"
"fmt"
"os"
"path/filepath"
"time"

"github.com/mickamy/mulligan/internal/clonefile"
"github.com/mickamy/mulligan/internal/detect"
"github.com/mickamy/mulligan/internal/scope"
"github.com/mickamy/mulligan/internal/store"
)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change needed: this recommendation depends on the filepath.Join concern above, which is a false positive (Go's Join keeps earlier elements even when a later element is absolute), so no strings-based rewrite is required.

Comment on lines +3 to +10
import (
"os"
"path/filepath"
"testing"

"github.com/mickamy/mulligan/internal/snap"
"github.com/mickamy/mulligan/internal/store"
)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change needed: this recommendation depends on the filepath.Join concern above, which is a false positive (Go's Join keeps earlier elements even when a later element is absolute), so no strings-based rewrite is required.

Comment on lines +61 to +63
// The snapshot mirrors the absolute path under the data dir.
snapped := filepath.Join(gen.DataDir(), cwd, "build", "sub", "b.txt")
if got := readFile(t, snapped); got != "b" {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change needed: this recommendation depends on the filepath.Join concern above, which is a false positive (Go's Join keeps earlier elements even when a later element is absolute), so no strings-based rewrite is required.

Comment on lines +97 to +102
}

snapped := filepath.Join(gen.DataDir(), cwd, "config.yaml")
if got := readFile(t, snapped); got != "v: 1" {
t.Errorf("snapshot content = %q, want %q", got, "v: 1")
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change needed: this recommendation depends on the filepath.Join concern above, which is a false positive (Go's Join keeps earlier elements even when a later element is absolute), so no strings-based rewrite is required.

Comment thread internal/cli/cli_test.go
Comment on lines +139 to +142
snapped := filepath.Join(gen.DataDir(), cwd, "a.txt")
if _, err := os.Stat(snapped); err != nil {
t.Errorf("snapshot missing: %v", err)
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change needed: this recommendation depends on the filepath.Join concern above, which is a false positive (Go's Join keeps earlier elements even when a later element is absolute), so no strings-based rewrite is required.

Comment thread internal/store/store.go
Comment on lines +75 to +79
sum := sha256.Sum256([]byte(abs))
name := filepath.Base(abs) + "-" + hex.EncodeToString(sum[:4])

return Project{dir: filepath.Join(s.root, name), root: abs}, nil
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially right. The "outside the store root" part does not happen — Go's filepath.Join keeps earlier elements even when a later one is absolute, so Join(s.root, "/-abc123") still lands under s.root. But filepath.Base("/") returning / is real and would produce an awkward -<hash> directory name, so the base name is now sanitized to root for the filesystem root: d5a7557.

@mickamy
mickamy requested a review from Copilot June 6, 2026 06:43
@mickamy

mickamy commented Jun 6, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the core snapshotting pipeline for mulligan, a tool designed to undo destructive shell commands. It introduces command detection, scope decision strategies, an APFS-based zero-copy file cloning mechanism, an on-disk generation store, and a Zsh preexec hook. A high-severity issue was identified in the Supported function of the clonefile package, where a compilation error occurs due to an invalid slice conversion of Fstypename on macOS, and a logical bug exists where symlinks are followed instead of being evaluated directly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +27 to +42
func Supported(src, dstDir string) (bool, error) {
var srcFS, dstFS unix.Statfs_t

if err := unix.Statfs(src, &srcFS); err != nil {
return false, fmt.Errorf("statfs %s: %w", src, err)
}
if err := unix.Statfs(dstDir, &dstFS); err != nil {
return false, fmt.Errorf("statfs %s: %w", dstDir, err)
}

if unix.ByteSliceToString(srcFS.Fstypename[:]) != "apfs" {
return false, nil
}

return srcFS.Fsid == dstFS.Fsid, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

  • Severity: High
  • Issue: The current implementation of Supported has two major issues:
  1. It will fail to compile because srcFS.Fstypename is of type [16]int8 on macOS, which cannot be sliced into []byte for unix.ByteSliceToString.
  2. It uses unix.Statfs on src, which follows symlinks. Since clonefile is called with unix.CLONE_NOFOLLOW, symlinks themselves are cloned rather than their targets. If a symlink points to a target on a different volume, Supported will incorrectly check the target's volume instead of the symlink's volume.
  • Recommendation: Use unix.Lstat on src and unix.Stat on dstDir to compare their device IDs (Dev), and then verify that dstDir is on an APFS volume using unix.Statfs. Convert the Fstypename int8 array to a string manually to ensure compilation.
func Supported(src, dstDir string) (bool, error) {
	var srcStat, dstStat unix.Stat_t

	if err := unix.Lstat(src, &srcStat); err != nil {
		return false, fmt.Errorf("lstat %s: %w", src, err)
	}
	if err := unix.Stat(dstDir, &dstStat); err != nil {
		return false, fmt.Errorf("stat %s: %w", dstDir, err)
	}

	if srcStat.Dev != dstStat.Dev {
		return false, nil
	}

	var dstFS unix.Statfs_t
	if err := unix.Statfs(dstDir, &dstFS); err != nil {
		return false, fmt.Errorf("statfs %s: %w", dstDir, err)
	}

	var fstype []byte
	for _, b := range dstFS.Fstypename {
		if b == 0 {
			break
		}
		fstype = append(fstype, byte(b))
	}

	return string(fstype) == "apfs", nil
}
References
  1. Output Format: If there are any issues, you must output them strictly in the following format. Do not include any greetings or introductory explanations. - Severity: [High / Medium / Low] - Issue: (Briefly explain why it is a problem) - Recommendation: (Suggest how to fix or improve it) (link)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.

Comment thread internal/scope/scope.go
Comment on lines +188 to +194
status, rel := entry[:2], entry[3:]

if status[0] == 'R' || status[0] == 'C' {
i++ // the next field is the rename/copy source
}

p := filepath.Join(root, rel)
Comment thread internal/hook/hook.go
Comment on lines +10 to +17
//nolint:dupword // add-zsh-hook legitimately appears on consecutive lines
return `# mulligan: snapshot files before destructive commands
_mulligan_preexec() {
command mulligan snap --cmd "$3" 2>/dev/null
}
autoload -Uz add-zsh-hook
add-zsh-hook preexec _mulligan_preexec
`
Comment on lines +1 to +8
//go:build darwin

package clonefile

import (
"fmt"

"golang.org/x/sys/unix"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants