From 1f1164e6c47c789b8863baa65eb7a915fc636417 Mon Sep 17 00:00:00 2001 From: s4na Date: Thu, 9 Apr 2026 11:04:42 +0900 Subject: [PATCH 01/11] feat: add ghost-commit Go CLI and Homebrew formula Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + Formula/ghost-commit.rb | 19 +++++++++++++++++++ README.md | 39 ++++++++++++++++++++++++++++++++++++++- go.mod | 3 +++ main.go | 7 +++++++ 5 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Formula/ghost-commit.rb create mode 100644 go.mod create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8571e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/ghost-commit diff --git a/Formula/ghost-commit.rb b/Formula/ghost-commit.rb new file mode 100644 index 0000000..10f1301 --- /dev/null +++ b/Formula/ghost-commit.rb @@ -0,0 +1,19 @@ +class GhostCommit < Formula + desc "Prints hello world" + homepage "https://github.com/s4na/ghost-commit" + url "https://github.com/s4na/ghost-commit.git", + tag: "v0.1.0", + revision: "HEAD" + 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 + assert_equal "hello world", shell_output("#{bin}/ghost-commit").strip + end +end diff --git a/README.md b/README.md index 87f0060..c018f6c 100644 --- a/README.md +++ b/README.md @@ -1 +1,38 @@ -# ghost-commit \ No newline at end of file +# ghost-commit + +`ghost-commit` と入力すると `hello world` と表示されるだけの Go 製アプリケーション。 + +## Install + +Homebrew でインストールできます。 + +```sh +brew install s4na/ghost-commit/ghost-commit +``` + +上記はこのリポジトリを tap として利用する形式です。初回のみ tap の追加が必要です。 + +```sh +brew tap s4na/ghost-commit https://github.com/s4na/ghost-commit +brew install ghost-commit +``` + +または Formula を直接指定してインストールすることもできます。 + +```sh +brew install --HEAD https://raw.githubusercontent.com/s4na/ghost-commit/main/Formula/ghost-commit.rb +``` + +## Usage + +```sh +$ ghost-commit +hello world +``` + +## Build from source + +```sh +go build ./... +./ghost-commit +``` 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..c048119 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("hello world") +} From 5a92c38d57dc4f09a08e8abed627ece2582c8d74 Mon Sep 17 00:00:00 2001 From: s4na Date: Thu, 9 Apr 2026 19:38:23 +0900 Subject: [PATCH 02/11] docs: simplify Homebrew install instructions to tap + install Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index c018f6c..30e459d 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,11 @@ Homebrew でインストールできます。 -```sh -brew install s4na/ghost-commit/ghost-commit -``` - -上記はこのリポジトリを tap として利用する形式です。初回のみ tap の追加が必要です。 - ```sh brew tap s4na/ghost-commit https://github.com/s4na/ghost-commit brew install ghost-commit ``` -または Formula を直接指定してインストールすることもできます。 - -```sh -brew install --HEAD https://raw.githubusercontent.com/s4na/ghost-commit/main/Formula/ghost-commit.rb -``` - ## Usage ```sh From 74f231a9f6b6bc564460af96089036e02f2424ae Mon Sep 17 00:00:00 2001 From: s4na Date: Thu, 9 Apr 2026 19:56:19 +0900 Subject: [PATCH 03/11] fix: address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Formula を head-only に変更(不正な revision: "HEAD" と未作成タグの問題を解消) - MIT LICENSE ファイルを追加 - Go ユニットテストを追加 - .gitignore に *.exe を追加 - README の install/build コマンドを修正 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + Formula/ghost-commit.rb | 3 --- LICENSE | 21 +++++++++++++++++++++ README.md | 4 ++-- main_test.go | 18 ++++++++++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 LICENSE create mode 100644 main_test.go diff --git a/.gitignore b/.gitignore index f8571e5..af34405 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /ghost-commit +*.exe diff --git a/Formula/ghost-commit.rb b/Formula/ghost-commit.rb index 10f1301..4ad026b 100644 --- a/Formula/ghost-commit.rb +++ b/Formula/ghost-commit.rb @@ -1,9 +1,6 @@ class GhostCommit < Formula desc "Prints hello world" homepage "https://github.com/s4na/ghost-commit" - url "https://github.com/s4na/ghost-commit.git", - tag: "v0.1.0", - revision: "HEAD" license "MIT" head "https://github.com/s4na/ghost-commit.git", branch: "main" 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.md b/README.md index 30e459d..01ef7c5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Homebrew でインストールできます。 ```sh brew tap s4na/ghost-commit https://github.com/s4na/ghost-commit -brew install ghost-commit +brew install --HEAD ghost-commit ``` ## Usage @@ -21,6 +21,6 @@ hello world ## Build from source ```sh -go build ./... +go build -o ghost-commit . ./ghost-commit ``` diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..0d108cd --- /dev/null +++ b/main_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "os/exec" + "testing" +) + +func TestGhostCommitOutput(t *testing.T) { + out, err := exec.Command("go", "run", ".").Output() + if err != nil { + t.Fatalf("failed to run ghost-commit: %v", err) + } + got := string(out) + want := "hello world\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} From 56e71334a5f103d513e6c635d28b1035a4eb1962 Mon Sep 17 00:00:00 2001 From: s4na Date: Sat, 13 Jun 2026 22:39:29 +0900 Subject: [PATCH 04/11] Add GitHub Actions CI workflows Add Go build/test CI and zizmor workflow security scanning for the PR request to make merge readiness visible in GitHub Actions. --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ .github/workflows/zizmor.yml | 27 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/zizmor.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e537935 --- /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@9f698171ed81b15d1823a05fc7211befd50c8ae0 # 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..c673921 --- /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@9f698171ed81b15d1823a05fc7211befd50c8ae0 # 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 From 25bd1db38ed554b23d2532903fd548e8ae4db517 Mon Sep 17 00:00:00 2001 From: s4na Date: Sat, 13 Jun 2026 22:43:52 +0900 Subject: [PATCH 05/11] Fix checkout action pin for zizmor Use the commit SHA behind the checkout v6.0.3 tag so zizmor's ref-version check accepts the workflow pins. --- .github/workflows/ci.yml | 2 +- .github/workflows/zizmor.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e537935..3068eac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index c673921..865886f 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false From 30c0ec090c9a8b17c18e615329f1bf9bbf002f4e Mon Sep 17 00:00:00 2001 From: s4na Date: Sat, 13 Jun 2026 23:05:16 +0900 Subject: [PATCH 06/11] Implement virtual ghost commits Replace the placeholder CLI with the requested virtual staging workflow: commit supplied file contents and deletions without rewriting the working tree, while preserving unrelated staged changes. --- Formula/ghost-commit.rb | 18 ++- README.md | 27 +++- main.go | 338 +++++++++++++++++++++++++++++++++++++++- main_test.go | 222 +++++++++++++++++++++++++- 4 files changed, 590 insertions(+), 15 deletions(-) diff --git a/Formula/ghost-commit.rb b/Formula/ghost-commit.rb index 4ad026b..e63e4fc 100644 --- a/Formula/ghost-commit.rb +++ b/Formula/ghost-commit.rb @@ -1,5 +1,5 @@ class GhostCommit < Formula - desc "Prints hello world" + 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" @@ -11,6 +11,20 @@ def install end test do - assert_equal "hello world", shell_output("#{bin}/ghost-commit").strip + 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/README.md b/README.md index 01ef7c5..648b8b5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # ghost-commit -`ghost-commit` と入力すると `hello world` と表示されるだけの Go 製アプリケーション。 +`ghost-commit` は、実際のファイルを書き換えずに「別の内容のファイル」をコミットできる CLI です。 + +LLM が作った中間ファイルや、まだ作業ツリーに存在しないファイルを、そのまま指定したパスの内容としてコミットできます。 ## Install @@ -13,14 +15,31 @@ brew install --HEAD ghost-commit ## Usage +別ファイルの内容を `README.md` としてコミットします。 + +```sh +ghost-commit -m "README を更新" --file README.md=/tmp/llm-readme.md +``` + +標準入力から受け取った内容を、新しいファイルとしてコミットします。 + ```sh -$ ghost-commit -hello world +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 +./ghost-commit --help ``` diff --git a/main.go b/main.go index c048119..876d7e2 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,341 @@ package main -import "fmt" +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) + } + if err := validateGitPath(path); err != nil { + return err + } + if source == "" { + return fmt.Errorf("source for %q is empty", path) + } + *s = append(*s, fileSpec{path: path, source: source}) + return nil +} + +type deletePaths []string + +func (p *deletePaths) String() string { + return strings.Join(*p, ",") +} + +func (p *deletePaths) Set(value string) error { + if err := validateGitPath(value); err != nil { + return err + } + *p = append(*p, value) + return nil +} + +type options struct { + message string + files fileSpecs + deletes deletePaths +} + +type virtualEntry struct { + path string + mode string + blob string + delete bool +} func main() { - fmt.Println("hello world") + if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr, ""); err != nil { + 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} + 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 := 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 := g.run(indexEnv, "update-index", "--force-remove", "--", 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") + } + if err := rejectDuplicatePaths(opts); err != nil { + return opts, err + } + return opts, 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 validateGitPath(path 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 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) + } + 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{}{} + } + for path := range seen { + hasChanges, err := g.hasStagedChange(path) + if err != nil { + return fmt.Errorf("check staged changes for %q: %w", path, err) + } + if hasChanges { + return fmt.Errorf("%q already has staged changes; unstage it before ghost-commit", path) + } + } + return nil +} + +func syncIndexToGhostEntries(g gitRunner, entries []virtualEntry) error { + for _, entry := range entries { + if entry.delete { + if err := g.run(nil, "update-index", "--force-remove", "--", entry.path); err != nil { + return err + } + continue + } + if err := g.run(nil, "update-index", "--add", "--cacheinfo", entry.mode, entry.blob, entry.path); err != nil { + return err + } + } + return nil +} + +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) hasStagedChange(path string) (bool, error) { + cmd := g.command(nil, nil, "diff", "--cached", "--quiet", "--", path) + err := cmd.Run() + if err == nil { + return false, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return true, nil + } + return false, err +} + +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 index 0d108cd..d5cf32a 100644 --- a/main_test.go +++ b/main_test.go @@ -1,18 +1,226 @@ package main import ( + "bytes" + "os" "os/exec" + "path/filepath" + "strings" "testing" ) -func TestGhostCommitOutput(t *testing.T) { - out, err := exec.Command("go", "run", ".").Output() +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("failed to run ghost-commit: %v", err) + 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") } - got := string(out) - want := "hello world\n" - if got != want { - t.Errorf("got %q, want %q", got, want) +} + +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 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 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(), "already has 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 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 } From 61e1bef06ad3d0d3a856b8cd304c5a56049f69c6 Mon Sep 17 00:00:00 2001 From: s4na Date: Sat, 13 Jun 2026 23:05:52 +0900 Subject: [PATCH 07/11] Normalize ghost file paths Canonicalize requested repository paths before duplicate and staged-overlap checks so equivalent path spellings cannot bypass safety checks. --- main.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/main.go b/main.go index 876d7e2..297ab63 100644 --- a/main.go +++ b/main.go @@ -27,13 +27,14 @@ func (s *fileSpecs) Set(value string) error { if !ok { return fmt.Errorf("expected PATH=SOURCE, got %q", value) } - if err := validateGitPath(path); err != nil { + cleanPath, err := cleanGitPath(path) + if err != nil { return err } if source == "" { - return fmt.Errorf("source for %q is empty", path) + return fmt.Errorf("source for %q is empty", cleanPath) } - *s = append(*s, fileSpec{path: path, source: source}) + *s = append(*s, fileSpec{path: cleanPath, source: source}) return nil } @@ -44,10 +45,11 @@ func (p *deletePaths) String() string { } func (p *deletePaths) Set(value string) error { - if err := validateGitPath(value); err != nil { + cleanPath, err := cleanGitPath(value) + if err != nil { return err } - *p = append(*p, value) + *p = append(*p, cleanPath) return nil } @@ -226,21 +228,21 @@ func rejectDuplicatePaths(opts options) error { return nil } -func validateGitPath(path string) error { +func cleanGitPath(path string) (string, error) { if path == "" { - return errors.New("path is empty") + return "", errors.New("path is empty") } if filepath.IsAbs(path) { - return fmt.Errorf("path %q must be relative", 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) + return "", fmt.Errorf("path %q must stay inside the repository", path) } if strings.Contains(clean, "\x00") { - return errors.New("path contains NUL") + return "", errors.New("path contains NUL") } - return nil + return clean, nil } func modeForPath(g gitRunner, baseCommit, path string) (string, error) { From 9af0f1e9f8754b8bbe50fc673b8abab7029f9d99 Mon Sep 17 00:00:00 2001 From: s4na Date: Sat, 13 Jun 2026 23:12:41 +0900 Subject: [PATCH 08/11] Fix ghost path safety checks Resolve reviewer findings by making CLI paths relative to the invocation directory inside the repository and rejecting parent/child staged overlaps before moving HEAD. --- main.go | 88 +++++++++++++++++++++++++++++++++++++++++----------- main_test.go | 61 +++++++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 19 deletions(-) diff --git a/main.go b/main.go index 297ab63..1ade2ad 100644 --- a/main.go +++ b/main.go @@ -86,6 +86,23 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer, workdir strin } 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)} + base, err := g.output(nil, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { return fmt.Errorf("resolve HEAD: %w", err) @@ -205,12 +222,36 @@ func parseOptions(args []string, stderr io.Writer) (options, error) { if len(opts.files) == 0 && len(opts.deletes) == 0 { return opts, errors.New("at least one --file or --delete is required") } - if err := rejectDuplicatePaths(opts); err != nil { - return opts, err - } 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 { @@ -268,18 +309,24 @@ func rejectOverlappingStagedChanges(g gitRunner, opts options) error { for _, path := range opts.deletes { seen[path] = struct{}{} } + staged, err := g.stagedPaths() + if err != nil { + return err + } for path := range seen { - hasChanges, err := g.hasStagedChange(path) - if err != nil { - return fmt.Errorf("check staged changes for %q: %w", path, err) - } - if hasChanges { - return fmt.Errorf("%q already has staged changes; unstage it before ghost-commit", path) + 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 entry.delete { @@ -319,17 +366,22 @@ func (g gitRunner) outputWithStdin(extraEnv []string, stdin io.Reader, args ...s return string(out), nil } -func (g gitRunner) hasStagedChange(path string) (bool, error) { - cmd := g.command(nil, nil, "diff", "--cached", "--quiet", "--", path) - err := cmd.Run() - if err == nil { - return false, 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 } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { - return true, nil + parts := strings.Split(out, "\x00") + paths := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + paths = append(paths, part) + } } - return false, err + return paths, nil } func (g gitRunner) command(extraEnv []string, stdin io.Reader, args ...string) *exec.Cmd { diff --git a/main_test.go b/main_test.go index d5cf32a..0a6e63f 100644 --- a/main_test.go +++ b/main_test.go @@ -133,6 +133,34 @@ func TestDoesNotCommitOrClearExistingStagedChanges(t *testing.T) { } } +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 TestRejectsGhostPathWithExistingStagedChanges(t *testing.T) { repo := newRepo(t) writeFile(t, repo, "target.txt", "base\n") @@ -151,7 +179,7 @@ func TestRejectsGhostPathWithExistingStagedChanges(t *testing.T) { &bytes.Buffer{}, repo, ) - if err == nil || !strings.Contains(err.Error(), "already has staged changes") { + 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" { @@ -159,6 +187,37 @@ func TestRejectsGhostPathWithExistingStagedChanges(t *testing.T) { } } +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") From b2dc5d6df3581cc0e3c054000d878dc08f926b7b Mon Sep 17 00:00:00 2001 From: s4na Date: Sat, 13 Jun 2026 23:20:30 +0900 Subject: [PATCH 09/11] Support repository-relative edge paths Allow subdirectory invocations to target parent paths that remain inside the repository and handle virtual file/directory replacements by clearing conflicting index entries first. --- main.go | 74 ++++++++++++++++++++++++++++++++++--- main_test.go | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index 1ade2ad..a5edc64 100644 --- a/main.go +++ b/main.go @@ -27,7 +27,7 @@ func (s *fileSpecs) Set(value string) error { if !ok { return fmt.Errorf("expected PATH=SOURCE, got %q", value) } - cleanPath, err := cleanGitPath(path) + cleanPath, err := cleanUserPath(path) if err != nil { return err } @@ -45,7 +45,7 @@ func (p *deletePaths) String() string { } func (p *deletePaths) Set(value string) error { - cleanPath, err := cleanGitPath(value) + cleanPath, err := cleanUserPath(value) if err != nil { return err } @@ -158,6 +158,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer, workdir strin 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) } @@ -165,7 +168,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer, workdir strin } for _, path := range opts.deletes { - if err := g.run(indexEnv, "update-index", "--force-remove", "--", path); err != nil { + 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}) @@ -286,6 +289,23 @@ func cleanGitPath(path string) (string, error) { 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 { @@ -298,6 +318,9 @@ func modeForPath(g gitRunner, baseCommit, path string) (string, error) { if !ok || mode == "" { return "", fmt.Errorf("could not parse git mode for %q", path) } + if mode == "040000" { + return "100644", nil + } return mode, nil } @@ -329,10 +352,10 @@ func pathsOverlap(a, b string) bool { 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 { - if err := g.run(nil, "update-index", "--force-remove", "--", entry.path); err != nil { - return err - } continue } if err := g.run(nil, "update-index", "--add", "--cacheinfo", entry.mode, entry.blob, entry.path); err != nil { @@ -342,6 +365,27 @@ func syncIndexToGhostEntries(g gitRunner, entries []virtualEntry) error { 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 } @@ -384,6 +428,24 @@ func (g gitRunner) stagedPaths() ([]string, error) { return paths, 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 diff --git a/main_test.go b/main_test.go index 0a6e63f..2c834a2 100644 --- a/main_test.go +++ b/main_test.go @@ -161,6 +161,108 @@ func TestPathsAreRelativeToInvocationDirectory(t *testing.T) { } } +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") From 13ef50a15e7af5184b4546f8198984b92de5f0b8 Mon Sep 17 00:00:00 2001 From: s4na Date: Sat, 13 Jun 2026 23:26:42 +0900 Subject: [PATCH 10/11] Reject ghost commits during in-progress git operations Make help exit successfully and refuse to move HEAD while merge, rebase, cherry-pick, or revert state files are present. --- main.go | 40 ++++++++++++++++++++++++++++++++++++++++ main_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/main.go b/main.go index a5edc64..c613ab1 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,9 @@ type virtualEntry struct { 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) } @@ -102,6 +105,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer, workdir strin 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 { @@ -272,6 +278,28 @@ func rejectDuplicatePaths(opts options) error { 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") @@ -428,6 +456,18 @@ func (g gitRunner) stagedPaths() ([]string, error) { 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 { diff --git a/main_test.go b/main_test.go index 2c834a2..7ca68ed 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "flag" "os" "os/exec" "path/filepath" @@ -99,6 +100,13 @@ func TestReadsOneVirtualFileFromStdin(t *testing.T) { } } +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") @@ -133,6 +141,42 @@ func TestDoesNotCommitOrClearExistingStagedChanges(t *testing.T) { } } +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") From 4a2a2741804d31f6f36d744ada76aa51e1255903 Mon Sep 17 00:00:00 2001 From: s4na Date: Sat, 13 Jun 2026 23:32:57 +0900 Subject: [PATCH 11/11] Add Japanese README Add README.ja.md and cross-link the English and Japanese documentation for the PR request. --- README.ja.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 24 +++++++++++++----------- 2 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 README.ja.md 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 648b8b5..4a593fa 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # ghost-commit -`ghost-commit` は、実際のファイルを書き換えずに「別の内容のファイル」をコミットできる CLI です。 +[日本語](README.ja.md) -LLM が作った中間ファイルや、まだ作業ツリーに存在しないファイルを、そのまま指定したパスの内容としてコミットできます。 +`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 -Homebrew でインストールできます。 +Install with Homebrew: ```sh brew tap s4na/ghost-commit https://github.com/s4na/ghost-commit @@ -15,27 +17,27 @@ brew install --HEAD ghost-commit ## Usage -別ファイルの内容を `README.md` としてコミットします。 +Commit another file's contents as `README.md`. ```sh -ghost-commit -m "README を更新" --file README.md=/tmp/llm-readme.md +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 "設定を追加" --file config.yml=- +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 "古い設定を削除" --delete old-config.yml +ghost-commit -m "Remove old config" --delete old-config.yml ``` -`ghost-commit` は指定された仮想ファイルだけをコミットします。手元のファイルや、指定していない staging 済みの変更は変更しません。 +`ghost-commit` commits only the virtual files you specify. It does not rewrite your working files or alter unrelated staged changes. -コミット後に、手元のファイルが ghost commit の内容と違う場合は `git status` に差分として表示されます。これはファイルを書き換えたわけではなく、新しいコミットの内容と手元の状態が違うためです。 +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