diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3068eac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: Go test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: false + + - name: Build + run: go build ./... + + - name: Test + run: go test ./... diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..865886f --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,27 @@ +name: GitHub Actions Security + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + zizmor: + name: zizmor + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + with: + advanced-security: false + inputs: .github/workflows diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af34405 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/ghost-commit +*.exe diff --git a/Formula/ghost-commit.rb b/Formula/ghost-commit.rb new file mode 100644 index 0000000..e63e4fc --- /dev/null +++ b/Formula/ghost-commit.rb @@ -0,0 +1,30 @@ +class GhostCommit < Formula + desc "Commit virtual file contents without changing the working tree" + homepage "https://github.com/s4na/ghost-commit" + license "MIT" + head "https://github.com/s4na/ghost-commit.git", branch: "main" + + depends_on "go" => :build + + def install + system "go", "build", *std_go_args(ldflags: "-s -w") + end + + test do + mkdir testpath/"repo" do + system "git", "init" + system "git", "config", "user.name", "Homebrew Test" + system "git", "config", "user.email", "homebrew@example.test" + + (testpath/"repo/base.txt").write "base\n" + system "git", "add", "base.txt" + system "git", "commit", "-m", "initial" + + (testpath/"ghost.txt").write "ghost\n" + assert_match(/^[0-9a-f]{40}$/, + shell_output("#{bin}/ghost-commit -m 'ghost file' --file virtual.txt=#{testpath}/ghost.txt").strip) + assert_equal "ghost\n", shell_output("git show HEAD:virtual.txt") + refute_path_exists testpath/"repo/virtual.txt" + end + end +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7fb6ddf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 s4na + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000..3ccc9e8 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,47 @@ +# ghost-commit + +[English](README.md) + +`ghost-commit` は、実際のファイルを書き換えずに「別の内容のファイル」をコミットできる CLI です。 + +LLM が作った中間ファイルや、まだ作業ツリーに存在しないファイルを、そのまま指定したパスの内容としてコミットできます。 + +## Install + +Homebrew でインストールできます。 + +```sh +brew tap s4na/ghost-commit https://github.com/s4na/ghost-commit +brew install --HEAD ghost-commit +``` + +## Usage + +別ファイルの内容を `README.md` としてコミットします。 + +```sh +ghost-commit -m "README を更新" --file README.md=/tmp/llm-readme.md +``` + +標準入力から受け取った内容を、新しいファイルとしてコミットします。 + +```sh +cat /tmp/generated-config.yml | ghost-commit -m "設定を追加" --file config.yml=- +``` + +ファイルが存在しない状態もコミットできます。 + +```sh +ghost-commit -m "古い設定を削除" --delete old-config.yml +``` + +`ghost-commit` は指定された仮想ファイルだけをコミットします。手元のファイルや、指定していない staging 済みの変更は変更しません。 + +コミット後に、手元のファイルが ghost commit の内容と違う場合は `git status` に差分として表示されます。これはファイルを書き換えたわけではなく、新しいコミットの内容と手元の状態が違うためです。 + +## Build from source + +```sh +go build -o ghost-commit . +./ghost-commit --help +``` diff --git a/README.md b/README.md index 87f0060..4a593fa 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ -# ghost-commit \ No newline at end of file +# ghost-commit + +[日本語](README.ja.md) + +`ghost-commit` is a CLI for committing alternate file contents without rewriting the actual files in your working tree. + +It lets you commit intermediate files produced by an LLM, or files that do not exist in your working tree yet, as content at the paths you choose. + +## Install + +Install with Homebrew: + +```sh +brew tap s4na/ghost-commit https://github.com/s4na/ghost-commit +brew install --HEAD ghost-commit +``` + +## Usage + +Commit another file's contents as `README.md`. + +```sh +ghost-commit -m "Update README" --file README.md=/tmp/llm-readme.md +``` + +Commit content from stdin as a new file. + +```sh +cat /tmp/generated-config.yml | ghost-commit -m "Add config" --file config.yml=- +``` + +Commit a state where a file does not exist. + +```sh +ghost-commit -m "Remove old config" --delete old-config.yml +``` + +`ghost-commit` commits only the virtual files you specify. It does not rewrite your working files or alter unrelated staged changes. + +After a ghost commit, `git status` may show differences if your working files differ from the new commit. That happens because the commit changed, not because `ghost-commit` rewrote those files. + +## Build from source + +```sh +go build -o ghost-commit . +./ghost-commit --help +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7f26bf6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/s4na/ghost-commit + +go 1.26.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..c613ab1 --- /dev/null +++ b/main.go @@ -0,0 +1,497 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type fileSpec struct { + path string + source string +} + +type fileSpecs []fileSpec + +func (s *fileSpecs) String() string { + return fmt.Sprint([]fileSpec(*s)) +} + +func (s *fileSpecs) Set(value string) error { + path, source, ok := strings.Cut(value, "=") + if !ok { + return fmt.Errorf("expected PATH=SOURCE, got %q", value) + } + cleanPath, err := cleanUserPath(path) + if err != nil { + return err + } + if source == "" { + return fmt.Errorf("source for %q is empty", cleanPath) + } + *s = append(*s, fileSpec{path: cleanPath, source: source}) + return nil +} + +type deletePaths []string + +func (p *deletePaths) String() string { + return strings.Join(*p, ",") +} + +func (p *deletePaths) Set(value string) error { + cleanPath, err := cleanUserPath(value) + if err != nil { + return err + } + *p = append(*p, cleanPath) + return nil +} + +type options struct { + message string + files fileSpecs + deletes deletePaths +} + +type virtualEntry struct { + path string + mode string + blob string + delete bool +} + +func main() { + if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr, ""); err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer, workdir string) error { + opts, err := parseOptions(args, stderr) + if err != nil { + return err + } + if workdir == "" { + workdir, err = os.Getwd() + if err != nil { + return err + } + } + + g := gitRunner{workdir: workdir} + repoRoot, err := g.output(nil, "rev-parse", "--show-toplevel") + if err != nil { + return fmt.Errorf("resolve repository root: %w", err) + } + prefix, err := g.output(nil, "rev-parse", "--show-prefix") + if err != nil { + return fmt.Errorf("resolve repository prefix: %w", err) + } + opts, err = normalizeOptions(opts, workdir, strings.TrimSpace(repoRoot), strings.TrimSpace(prefix)) + if err != nil { + return err + } + if err := rejectDuplicatePaths(opts); err != nil { + return err + } + g = gitRunner{workdir: strings.TrimSpace(repoRoot)} + if err := rejectInProgressOperation(g); err != nil { + return err + } + + base, err := g.output(nil, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return fmt.Errorf("resolve HEAD: %w", err) + } + baseCommit := strings.TrimSpace(base) + + if err := rejectOverlappingStagedChanges(g, opts); err != nil { + return err + } + + tmp, err := os.CreateTemp("", "ghost-commit-index-*") + if err != nil { + return err + } + indexPath := tmp.Name() + if err := tmp.Close(); err != nil { + return err + } + if err := os.Remove(indexPath); err != nil { + return err + } + defer os.Remove(indexPath) + + indexEnv := []string{"GIT_INDEX_FILE=" + indexPath} + if err := g.run(indexEnv, "read-tree", baseCommit); err != nil { + return fmt.Errorf("prepare virtual stage: %w", err) + } + + stdinUsed := false + var entries []virtualEntry + for _, spec := range opts.files { + var r io.Reader + if spec.source == "-" { + if stdinUsed { + return errors.New("only one --file entry can read from stdin") + } + stdinUsed = true + r = stdin + } else { + source, err := os.Open(spec.source) + if err != nil { + return fmt.Errorf("open source for %q: %w", spec.path, err) + } + defer source.Close() + r = source + } + + blob, err := g.outputWithStdin(nil, r, "hash-object", "-w", "--stdin") + if err != nil { + return fmt.Errorf("store blob for %q: %w", spec.path, err) + } + mode, err := modeForPath(g, baseCommit, spec.path) + if err != nil { + return err + } + if err := removeIndexConflicts(g, indexEnv, spec.path); err != nil { + return fmt.Errorf("clear virtual index conflicts for %q: %w", spec.path, err) + } + if err := g.run(indexEnv, "update-index", "--add", "--cacheinfo", mode, strings.TrimSpace(blob), spec.path); err != nil { + return fmt.Errorf("stage virtual file %q: %w", spec.path, err) + } + entries = append(entries, virtualEntry{path: spec.path, mode: mode, blob: strings.TrimSpace(blob)}) + } + + for _, path := range opts.deletes { + if err := removeIndexConflicts(g, indexEnv, path); err != nil { + return fmt.Errorf("stage virtual delete %q: %w", path, err) + } + entries = append(entries, virtualEntry{path: path, delete: true}) + } + + tree, err := g.output(indexEnv, "write-tree") + if err != nil { + return fmt.Errorf("write virtual tree: %w", err) + } + tree = strings.TrimSpace(tree) + + baseTree, err := g.output(nil, "rev-parse", baseCommit+"^{tree}") + if err != nil { + return fmt.Errorf("resolve base tree: %w", err) + } + if tree == strings.TrimSpace(baseTree) { + return errors.New("ghost changes produced no commit") + } + + commit, err := g.outputWithStdin(nil, strings.NewReader(opts.message), "commit-tree", tree, "-p", baseCommit, "-F", "-") + if err != nil { + return fmt.Errorf("create commit: %w", err) + } + newCommit := strings.TrimSpace(commit) + + if err := g.run(nil, "update-ref", "-m", "ghost-commit", "HEAD", newCommit, baseCommit); err != nil { + return fmt.Errorf("move HEAD to ghost commit: %w", err) + } + if err := syncIndexToGhostEntries(g, entries); err != nil { + return fmt.Errorf("sync regular index to ghost commit: %w", err) + } + + fmt.Fprintf(stdout, "%s\n", newCommit) + return nil +} + +func parseOptions(args []string, stderr io.Writer) (options, error) { + var opts options + fs := flag.NewFlagSet("ghost-commit", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.StringVar(&opts.message, "m", "", "commit message") + fs.StringVar(&opts.message, "message", "", "commit message") + fs.Var(&opts.files, "file", "stage virtual file as PATH=SOURCE; use SOURCE=- to read stdin") + fs.Var(&opts.deletes, "delete", "stage virtual deletion for PATH") + if err := fs.Parse(args); err != nil { + return opts, err + } + if fs.NArg() != 0 { + return opts, fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")) + } + if strings.TrimSpace(opts.message) == "" { + return opts, errors.New("commit message is required: use -m or --message") + } + if len(opts.files) == 0 && len(opts.deletes) == 0 { + return opts, errors.New("at least one --file or --delete is required") + } + return opts, nil +} + +func normalizeOptions(opts options, workdir, repoRoot, prefix string) (options, error) { + var normalized options + normalized.message = opts.message + for _, spec := range opts.files { + path, err := cleanGitPath(prefix + spec.path) + if err != nil { + return normalized, err + } + source := spec.source + if source != "-" && !filepath.IsAbs(source) { + source = filepath.Join(workdir, source) + } + normalized.files = append(normalized.files, fileSpec{path: path, source: source}) + } + for _, path := range opts.deletes { + normalizedPath, err := cleanGitPath(prefix + path) + if err != nil { + return normalized, err + } + normalized.deletes = append(normalized.deletes, normalizedPath) + } + if repoRoot == "" { + return normalized, errors.New("repository root is empty") + } + return normalized, nil +} + +func rejectDuplicatePaths(opts options) error { + seen := make(map[string]struct{}) + for _, spec := range opts.files { + if _, ok := seen[spec.path]; ok { + return fmt.Errorf("path %q was specified more than once", spec.path) + } + seen[spec.path] = struct{}{} + } + for _, path := range opts.deletes { + if _, ok := seen[path]; ok { + return fmt.Errorf("path %q was specified more than once", path) + } + seen[path] = struct{}{} + } + return nil +} + +func rejectInProgressOperation(g gitRunner) error { + checks := map[string]string{ + "MERGE_HEAD": "merge", + "CHERRY_PICK_HEAD": "cherry-pick", + "REVERT_HEAD": "revert", + "rebase-merge": "rebase", + "rebase-apply": "rebase", + } + for gitPath, operation := range checks { + path, err := g.gitPath(gitPath) + if err != nil { + return err + } + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("refusing to create a ghost commit during an in-progress %s", operation) + } else if !os.IsNotExist(err) { + return err + } + } + return nil +} + +func cleanGitPath(path string) (string, error) { + if path == "" { + return "", errors.New("path is empty") + } + if filepath.IsAbs(path) { + return "", fmt.Errorf("path %q must be relative", path) + } + clean := filepath.ToSlash(filepath.Clean(path)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("path %q must stay inside the repository", path) + } + if strings.Contains(clean, "\x00") { + return "", errors.New("path contains NUL") + } + return clean, nil +} + +func cleanUserPath(path string) (string, error) { + if path == "" { + return "", errors.New("path is empty") + } + if filepath.IsAbs(path) { + return "", fmt.Errorf("path %q must be relative", path) + } + clean := filepath.ToSlash(filepath.Clean(path)) + if clean == "." { + return "", fmt.Errorf("path %q must name a file", path) + } + if strings.Contains(clean, "\x00") { + return "", errors.New("path contains NUL") + } + return clean, nil +} + +func modeForPath(g gitRunner, baseCommit, path string) (string, error) { + out, err := g.output(nil, "ls-tree", "-z", baseCommit, "--", path) + if err != nil { + return "", fmt.Errorf("read mode for %q: %w", path, err) + } + if out == "" { + return "100644", nil + } + mode, _, ok := strings.Cut(out, " ") + if !ok || mode == "" { + return "", fmt.Errorf("could not parse git mode for %q", path) + } + if mode == "040000" { + return "100644", nil + } + return mode, nil +} + +func rejectOverlappingStagedChanges(g gitRunner, opts options) error { + seen := make(map[string]struct{}) + for _, spec := range opts.files { + seen[spec.path] = struct{}{} + } + for _, path := range opts.deletes { + seen[path] = struct{}{} + } + staged, err := g.stagedPaths() + if err != nil { + return err + } + for path := range seen { + for _, stagedPath := range staged { + if pathsOverlap(path, stagedPath) { + return fmt.Errorf("%q overlaps staged changes at %q; unstage them before ghost-commit", path, stagedPath) + } + } + } + return nil +} + +func pathsOverlap(a, b string) bool { + return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/") +} + +func syncIndexToGhostEntries(g gitRunner, entries []virtualEntry) error { + for _, entry := range entries { + if err := removeIndexConflicts(g, nil, entry.path); err != nil { + return err + } + if entry.delete { + continue + } + if err := g.run(nil, "update-index", "--add", "--cacheinfo", entry.mode, entry.blob, entry.path); err != nil { + return err + } + } + return nil +} + +func removeIndexConflicts(g gitRunner, extraEnv []string, path string) error { + paths, err := g.indexPaths(extraEnv, path) + if err != nil { + return err + } + paths = append(paths, parentPaths(path)...) + if len(paths) == 0 { + return nil + } + args := append([]string{"update-index", "--force-remove", "--"}, paths...) + return g.run(extraEnv, args...) +} + +func parentPaths(path string) []string { + var parents []string + for dir := filepath.ToSlash(filepath.Dir(path)); dir != "." && dir != "/"; dir = filepath.ToSlash(filepath.Dir(dir)) { + parents = append(parents, dir) + } + return parents +} + +type gitRunner struct { + workdir string +} + +func (g gitRunner) run(extraEnv []string, args ...string) error { + out, err := g.command(extraEnv, nil, args...).CombinedOutput() + if err != nil { + return fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, string(out)) + } + return nil +} + +func (g gitRunner) output(extraEnv []string, args ...string) (string, error) { + return g.outputWithStdin(extraEnv, nil, args...) +} + +func (g gitRunner) outputWithStdin(extraEnv []string, stdin io.Reader, args ...string) (string, error) { + out, err := g.command(extraEnv, stdin, args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, string(out)) + } + return string(out), nil +} + +func (g gitRunner) stagedPaths() ([]string, error) { + out, err := g.output(nil, "diff", "--cached", "--name-only", "-z") + if err != nil { + return nil, fmt.Errorf("list staged paths: %w", err) + } + if out == "" { + return nil, nil + } + parts := strings.Split(out, "\x00") + paths := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + paths = append(paths, part) + } + } + return paths, nil +} + +func (g gitRunner) gitPath(path string) (string, error) { + out, err := g.output(nil, "rev-parse", "--git-path", path) + if err != nil { + return "", fmt.Errorf("resolve git path %q: %w", path, err) + } + resolved := strings.TrimSpace(out) + if filepath.IsAbs(resolved) { + return resolved, nil + } + return filepath.Join(g.workdir, resolved), nil +} + +func (g gitRunner) indexPaths(extraEnv []string, path string) ([]string, error) { + out, err := g.output(extraEnv, "ls-files", "-z", "--", path) + if err != nil { + return nil, fmt.Errorf("list index paths for %q: %w", path, err) + } + if out == "" { + return nil, nil + } + parts := strings.Split(out, "\x00") + paths := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + paths = append(paths, part) + } + } + return paths, nil +} + +func (g gitRunner) command(extraEnv []string, stdin io.Reader, args ...string) *exec.Cmd { + cmd := exec.Command("git", args...) + cmd.Dir = g.workdir + cmd.Env = append(os.Environ(), extraEnv...) + if stdin != nil { + cmd.Stdin = stdin + } + return cmd +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..7ca68ed --- /dev/null +++ b/main_test.go @@ -0,0 +1,431 @@ +package main + +import ( + "bytes" + "flag" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCommitsVirtualFileWithoutChangingWorkingTree(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "README.md", "base\n") + git(t, repo, "add", "README.md") + git(t, repo, "commit", "-m", "initial") + + writeFile(t, repo, "README.md", "local draft\n") + source := filepath.Join(t.TempDir(), "README.llm.md") + writeFile(t, filepath.Dir(source), filepath.Base(source), "ghost content\n") + + var stdout bytes.Buffer + err := run([]string{"-m", "commit virtual README", "--file", "README.md=" + source}, strings.NewReader(""), &stdout, &bytes.Buffer{}, repo) + if err != nil { + t.Fatalf("run ghost-commit: %v", err) + } + + if got := readFile(t, repo, "README.md"); got != "local draft\n" { + t.Fatalf("working tree changed: got %q", got) + } + if got := gitOutput(t, repo, "show", "HEAD:README.md"); got != "ghost content\n" { + t.Fatalf("committed README.md = %q", got) + } + if got := strings.TrimSpace(gitOutput(t, repo, "log", "-1", "--format=%s")); got != "commit virtual README" { + t.Fatalf("commit subject = %q", got) + } + if strings.TrimSpace(stdout.String()) == "" { + t.Fatal("expected new commit SHA on stdout") + } +} + +func TestAddsAndDeletesVirtualFilesWithoutTouchingWorkingTree(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "old.txt", "still on disk\n") + git(t, repo, "add", "old.txt") + git(t, repo, "commit", "-m", "initial") + + sourceDir := t.TempDir() + writeFile(t, sourceDir, "new.txt", "new virtual file\n") + + err := run( + []string{ + "-m", "replace files virtually", + "--file", "docs/new.txt=" + filepath.Join(sourceDir, "new.txt"), + "--delete", "old.txt", + }, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + repo, + ) + if err != nil { + t.Fatalf("run ghost-commit: %v", err) + } + + if got := readFile(t, repo, "old.txt"); got != "still on disk\n" { + t.Fatalf("working tree deletion touched disk: got %q", got) + } + if got := gitOutput(t, repo, "show", "HEAD:docs/new.txt"); got != "new virtual file\n" { + t.Fatalf("committed docs/new.txt = %q", got) + } + if err := exec.Command("git", "-C", repo, "show", "HEAD:old.txt").Run(); err == nil { + t.Fatal("old.txt still exists in HEAD") + } +} + +func TestReadsOneVirtualFileFromStdin(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "base.txt", "base\n") + git(t, repo, "add", "base.txt") + git(t, repo, "commit", "-m", "initial") + + err := run( + []string{"-m", "add stdin file", "--file", "from-stdin.txt=-"}, + strings.NewReader("stdin content\n"), + &bytes.Buffer{}, + &bytes.Buffer{}, + repo, + ) + if err != nil { + t.Fatalf("run ghost-commit: %v", err) + } + + if got := gitOutput(t, repo, "show", "HEAD:from-stdin.txt"); got != "stdin content\n" { + t.Fatalf("committed stdin file = %q", got) + } + if _, err := os.Stat(filepath.Join(repo, "from-stdin.txt")); !os.IsNotExist(err) { + t.Fatalf("stdin file should not be created in working tree, stat err = %v", err) + } +} + +func TestHelpReturnsSuccess(t *testing.T) { + err := run([]string{"--help"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, t.TempDir()) + if err != flag.ErrHelp { + t.Fatalf("expected flag.ErrHelp, got %v", err) + } +} + +func TestDoesNotCommitOrClearExistingStagedChanges(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "base.txt", "base\n") + git(t, repo, "add", "base.txt") + git(t, repo, "commit", "-m", "initial") + + writeFile(t, repo, "already-staged.txt", "keep staged\n") + git(t, repo, "add", "already-staged.txt") + + source := filepath.Join(t.TempDir(), "ghost.txt") + writeFile(t, filepath.Dir(source), filepath.Base(source), "ghost\n") + + err := run( + []string{"-m", "ghost only", "--file", "ghost.txt=" + source}, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + repo, + ) + if err != nil { + t.Fatalf("run ghost-commit: %v", err) + } + + if got := gitOutput(t, repo, "show", "HEAD:ghost.txt"); got != "ghost\n" { + t.Fatalf("committed ghost.txt = %q", got) + } + if err := exec.Command("git", "-C", repo, "show", "HEAD:already-staged.txt").Run(); err == nil { + t.Fatal("pre-existing staged file was included in ghost commit") + } + if got := strings.TrimSpace(gitOutput(t, repo, "diff", "--cached", "--name-only")); got != "already-staged.txt" { + t.Fatalf("pre-existing staged file was not preserved, got %q", got) + } +} + +func TestRejectsInProgressMergeBeforeMovingHead(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "conflict.txt", "base\n") + git(t, repo, "add", "conflict.txt") + git(t, repo, "commit", "-m", "initial") + + git(t, repo, "checkout", "-b", "other") + writeFile(t, repo, "conflict.txt", "other\n") + git(t, repo, "commit", "-am", "other change") + git(t, repo, "checkout", "master") + writeFile(t, repo, "conflict.txt", "master\n") + git(t, repo, "commit", "-am", "master change") + before := strings.TrimSpace(gitOutput(t, repo, "rev-parse", "HEAD")) + + if out, err := gitCmd(repo, "merge", "other").CombinedOutput(); err == nil { + t.Fatalf("expected merge conflict, got success:\n%s", out) + } + source := filepath.Join(t.TempDir(), "ghost.txt") + writeFile(t, filepath.Dir(source), filepath.Base(source), "ghost\n") + + err := run( + []string{"-m", "ghost during merge", "--file", "ghost.txt=" + source}, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + repo, + ) + if err == nil || !strings.Contains(err.Error(), "in-progress merge") { + t.Fatalf("expected in-progress merge rejection, got %v", err) + } + after := strings.TrimSpace(gitOutput(t, repo, "rev-parse", "HEAD")) + if after != before { + t.Fatalf("HEAD moved despite merge rejection: before %s after %s", before, after) + } +} + +func TestPathsAreRelativeToInvocationDirectory(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "sub/a.txt", "base\n") + git(t, repo, "add", "sub/a.txt") + git(t, repo, "commit", "-m", "initial") + + source := filepath.Join(t.TempDir(), "a.txt") + writeFile(t, filepath.Dir(source), filepath.Base(source), "ghost from subdir\n") + + err := run( + []string{"-m", "ghost from subdir", "--file", "a.txt=" + source}, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + filepath.Join(repo, "sub"), + ) + if err != nil { + t.Fatalf("run ghost-commit: %v", err) + } + + if got := gitOutput(t, repo, "show", "HEAD:sub/a.txt"); got != "ghost from subdir\n" { + t.Fatalf("committed sub/a.txt = %q", got) + } + if err := exec.Command("git", "-C", repo, "show", "HEAD:a.txt").Run(); err == nil { + t.Fatal("ghost file was committed at repository root instead of invocation directory") + } +} + +func TestParentPathsCanStayInsideRepositoryFromSubdirectory(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "README.md", "base\n") + git(t, repo, "add", "README.md") + git(t, repo, "commit", "-m", "initial") + if err := os.Mkdir(filepath.Join(repo, "sub"), 0o755); err != nil { + t.Fatal(err) + } + + source := filepath.Join(t.TempDir(), "README.md") + writeFile(t, filepath.Dir(source), filepath.Base(source), "ghost parent\n") + + err := run( + []string{"-m", "ghost parent", "--file", "../README.md=" + source}, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + filepath.Join(repo, "sub"), + ) + if err != nil { + t.Fatalf("run ghost-commit: %v", err) + } + if got := gitOutput(t, repo, "show", "HEAD:README.md"); got != "ghost parent\n" { + t.Fatalf("committed README.md = %q", got) + } +} + +func TestRejectsParentPathsEscapingRepositoryFromRoot(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "base.txt", "base\n") + git(t, repo, "add", "base.txt") + git(t, repo, "commit", "-m", "initial") + + source := filepath.Join(t.TempDir(), "outside.txt") + writeFile(t, filepath.Dir(source), filepath.Base(source), "outside\n") + + err := run( + []string{"-m", "outside", "--file", "../outside.txt=" + source}, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + repo, + ) + if err == nil || !strings.Contains(err.Error(), "must stay inside the repository") { + t.Fatalf("expected escape rejection, got %v", err) + } +} + +func TestReplacesDirectoryWithVirtualFile(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "dir/a.txt", "child\n") + git(t, repo, "add", "dir/a.txt") + git(t, repo, "commit", "-m", "initial") + + source := filepath.Join(t.TempDir(), "dir") + writeFile(t, filepath.Dir(source), filepath.Base(source), "file now\n") + + err := run( + []string{"-m", "replace dir with file", "--file", "dir=" + source}, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + repo, + ) + if err != nil { + t.Fatalf("run ghost-commit: %v", err) + } + if got := gitOutput(t, repo, "show", "HEAD:dir"); got != "file now\n" { + t.Fatalf("committed dir file = %q", got) + } + if err := exec.Command("git", "-C", repo, "show", "HEAD:dir/a.txt").Run(); err == nil { + t.Fatal("directory child still exists in HEAD") + } +} + +func TestReplacesFileWithVirtualDirectoryChild(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "dir", "file first\n") + git(t, repo, "add", "dir") + git(t, repo, "commit", "-m", "initial") + + source := filepath.Join(t.TempDir(), "a.txt") + writeFile(t, filepath.Dir(source), filepath.Base(source), "child now\n") + + err := run( + []string{"-m", "replace file with dir child", "--file", "dir/a.txt=" + source}, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + repo, + ) + if err != nil { + t.Fatalf("run ghost-commit: %v", err) + } + if got := gitOutput(t, repo, "show", "HEAD:dir/a.txt"); got != "child now\n" { + t.Fatalf("committed dir/a.txt = %q", got) + } + if got := strings.TrimSpace(gitOutput(t, repo, "cat-file", "-t", "HEAD:dir")); got != "tree" { + t.Fatalf("dir should be a tree after replacement, got %q", got) + } +} + +func TestRejectsGhostPathWithExistingStagedChanges(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "target.txt", "base\n") + git(t, repo, "add", "target.txt") + git(t, repo, "commit", "-m", "initial") + + writeFile(t, repo, "target.txt", "already staged\n") + git(t, repo, "add", "target.txt") + source := filepath.Join(t.TempDir(), "target.txt") + writeFile(t, filepath.Dir(source), filepath.Base(source), "ghost\n") + + err := run( + []string{"-m", "ghost target", "--file", "target.txt=" + source}, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + repo, + ) + if err == nil || !strings.Contains(err.Error(), "overlaps staged changes") { + t.Fatalf("expected staged-overlap error, got %v", err) + } + if got := gitOutput(t, repo, "show", "HEAD:target.txt"); got != "base\n" { + t.Fatalf("HEAD changed despite rejection: %q", got) + } +} + +func TestRejectsParentChildStagedOverlapBeforeMovingHead(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "base.txt", "base\n") + git(t, repo, "add", "base.txt") + git(t, repo, "commit", "-m", "initial") + before := strings.TrimSpace(gitOutput(t, repo, "rev-parse", "HEAD")) + + writeFile(t, repo, "dir", "already staged parent\n") + git(t, repo, "add", "dir") + source := filepath.Join(t.TempDir(), "child.txt") + writeFile(t, filepath.Dir(source), filepath.Base(source), "ghost child\n") + + err := run( + []string{"-m", "ghost child", "--file", "dir/a.txt=" + source}, + strings.NewReader(""), + &bytes.Buffer{}, + &bytes.Buffer{}, + repo, + ) + if err == nil || !strings.Contains(err.Error(), "overlaps staged changes") { + t.Fatalf("expected staged parent/child overlap error, got %v", err) + } + after := strings.TrimSpace(gitOutput(t, repo, "rev-parse", "HEAD")) + if after != before { + t.Fatalf("HEAD moved despite rejection: before %s after %s", before, after) + } + if got := strings.TrimSpace(gitOutput(t, repo, "diff", "--cached", "--name-only")); got != "dir" { + t.Fatalf("staged parent was not preserved, got %q", got) + } +} + +func TestRejectsNoopGhostCommit(t *testing.T) { + repo := newRepo(t) + writeFile(t, repo, "same.txt", "same\n") + git(t, repo, "add", "same.txt") + git(t, repo, "commit", "-m", "initial") + + source := filepath.Join(t.TempDir(), "same.txt") + writeFile(t, filepath.Dir(source), filepath.Base(source), "same\n") + + err := run([]string{"-m", "same", "--file", "same.txt=" + source}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, repo) + if err == nil || !strings.Contains(err.Error(), "no commit") { + t.Fatalf("expected no-op error, got %v", err) + } +} + +func newRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + git(t, repo, "init") + git(t, repo, "config", "user.name", "Ghost Commit Test") + git(t, repo, "config", "user.email", "ghost-commit@example.test") + return repo +} + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, dir, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func git(t *testing.T, repo string, args ...string) { + t.Helper() + if out, err := gitCmd(repo, args...).CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func gitOutput(t *testing.T, repo string, args ...string) string { + t.Helper() + out, err := gitCmd(repo, args...).CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +func gitCmd(repo string, args ...string) *exec.Cmd { + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1") + return cmd +}