Skip to content

feat: add --name flag for reading file bytes from stdin#20

Merged
fank merged 3 commits into
mainfrom
claude/stdin-input
Apr 11, 2026
Merged

feat: add --name flag for reading file bytes from stdin#20
fank merged 3 commits into
mainfrom
claude/stdin-input

Conversation

@fank
Copy link
Copy Markdown
Member

@fank fank commented Apr 11, 2026

Summary

Adds a way to upload images from stdin instead of disk, so tools that emit bytes to a pipe — screen capture, image processing, clipboard readers — can compose directly with gh attach:

screencapture -i -t png -             | gh attach --name shot.png 123 -
grim -g "$(slurp)" -                  | gh attach --name region.png 123 -
magick input.png -resize 50% -        | gh attach --name small.png --key docs/diagram -
pbpaste                               | gh attach --name clipboard.png 42 -

Pass - as the single file argument and --name BASENAME to supply the git tree entry + embed URL name. Works with --key, --comment, --json, and --repo the same way a disk-backed upload does.

How it works

  • runDeps now carries stdin io.Reader (moved from a positional parameter on runWithDeps/runDelete, since two subcommands — delete confirmation + stdin upload — need it).
  • When - is the sole file arg, materializeStdin drains the reader into a fresh os.MkdirTemp directory under --name, and defers cleanup.
  • gh.PushAttachments still receives a normal filesystem path — the gh package is untouched.

Validation

  • --name is required with -, rejected without.
  • --name must be a safe basename: non-empty, ≤255 bytes, no / / \, not . or .., no NUL bytes.
  • - mixed with any other file arg is an explicit error (rather than an opaque "no files matched" from expandFiles).
  • Empty stdin is allowed (produces a 0-byte upload) so upstream tools that pipe nothing fail loud at the embed step, not silently here.

Test plan

  • TestValidateName — 7 valid + 8 invalid basenames
  • TestMaterializeStdin — happy path, empty input, cleanup, io.Copy error branch via an errReader
  • TestRunUpload_stdin_issue_mode — end-to-end byte fidelity via fakeGitClient.savePushContent
  • TestRunUpload_stdin_key_mode — refPath = uploads/misc/<key>, bytes match
  • TestRunUpload_stdin_with_commentUpsertComment fires, Commented: stderr line present
  • TestRunUpload_stdin_empty_allowed — 0-byte file passed through cleanly
  • TestRunUpload_stdin_json — JSON output suppresses stderr, parses back cleanly
  • TestRunUpload_stdin_via_runWithDeps — flag parsing + positional extraction end-to-end
  • TestRunUpload_stdin_arg_conflicts — 6 table cases (dash without name, name without dash, dash mixed with files, path in name, . as name, etc.)
  • All existing tests still pass (15 runUpload call sites updated for the new name parameter)
  • go vet clean, golangci-lint 0 issues
  • Coverage: 95.2% → 96.2% (materializeStdin 55.6% → 72.2% — remaining uncovered branches require OS-level fault injection)
  • End-to-end smoke test against enthus-appdev/gh-attach#19 in both issue and key modes, with byte-level verification of the uploaded blob via the /contents API. Test refs + issue cleaned up.

Pass `-` as the single file arg with --name BASENAME to read image
bytes from stdin, so tools like screencapture, magick, and pbpaste
can pipe directly into `gh attach` without a temp file:

    screencapture -i -t png - | gh attach --name shot.png 123 -
    magick input.png -resize 50% - | gh attach --name small.png --key docs/diagram -
    pbpaste | gh attach --name clipboard.png 42 -

## How it works

The stdin reader is threaded through runDeps (same spot the delete
confirmation prompt reads from) and, when `-` is the sole file arg,
materialized into a fresh `os.MkdirTemp` directory under the
user-chosen basename. gh.PushAttachments then sees a normal
filesystem path and stays completely unchanged — the gh package
never learns about stdin. A deferred cleanup closure removes the
temp dir when runUpload returns.

## Validation

--name is required with `-`, rejected without, and validated as a
safe basename: non-empty, <=255 bytes, no `/` or `\`, not `.` or
`..`, no NUL bytes. `-` mixed with other file args is an error
(scanning the positional list gives a clear message instead of an
opaque "no files matched" from expandFiles).

Empty stdin is allowed (produces a 0-byte upload) so upstream tools
that pipe nothing fail loud at the subsequent embed instead of
silently no-op'ing here.

Works with --key, --comment, --json, and --repo exactly like a
disk-backed upload.

## Refactor along the way

Moved `stdin io.Reader` from a positional parameter on runWithDeps
and runDelete into runDeps itself, now that two subcommands
(delete confirmation + upload-from-stdin) need to read it. Keeps
the subcommand signatures short and matches how other injected
dependencies are passed. Test helpers (happyDeps, deleteDeps,
listDeps) default to `strings.NewReader("")` so tests that don't
care about stdin still have a valid reader.

## Test + coverage

New tests in internal/cli:
- TestValidateName (2 subtests, 15 cases)
- TestMaterializeStdin (4 subtests: happy path, empty input,
  cleanup, io.Copy error via errReader)
- TestRunUpload_stdin_issue_mode / _key_mode / _with_comment /
  _empty_allowed / _json / _via_runWithDeps
- TestRunUpload_stdin_arg_conflicts (6 table cases)

fakeGitClient gains an optional `savePushContent` flag that
snapshots file bytes at PushAttachments call time; stdin tests
use it because runUpload's deferred temp cleanup removes the file
before the test body runs assertions.

Total coverage: 96.2% (95.2% → 96.2% within this branch).

## Smoke test

End-to-end tested against #19 in both
issue and key modes, including byte-level verification of the
uploaded blob via the /contents API (base64 round-trip matched
the piped 1x1 PNG exactly). Test refs + issue were cleaned up
afterward.
Copy link
Copy Markdown

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

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 introduces the ability to upload files from stdin by passing - as a file argument and providing a mandatory --name flag. The implementation includes new utility functions for filename validation and temporary file materialization, alongside updates to the dependency injection structure to support stdin across subcommands. Review feedback suggests enhancing the filename validation to include more reserved characters for better cross-platform support and refactoring the runUpload function to use an options struct to improve readability and maintainability.

Comment thread internal/cli/files.go Outdated
Comment thread internal/cli/run.go Outdated
fank added 2 commits April 11, 2026 12:11
The coverage bot flagged 5 new uncovered statements in files.go
(materializeStdin's MkdirTemp / Create / Close error branches, which
real OS fault injection can't reach cleanly) and 1 in run.go (the
`if stdinErr != nil { return stdinErr }` branch in runUpload).

## files.go refactor

Introduce a tiny injection seam: stdinFS { mkdirTemp, create } and
defaultStdinFS wiring those to os.MkdirTemp + a lambda around
os.Create (so the signature can return the narrower io.WriteCloser
instead of *os.File, which keeps fakes trivial). The public
materializeStdin wrapper is unchanged and just delegates to the new
materializeStdinWith(fs, stdin, name). Production call sites stay
exactly the same.

## New tests

TestMaterializeStdinWith (4 subtests):
- mkdirTemp error — returns immediately, no temp dir to clean
- create error — asserts the real temp dir is removed after failure
- close error — fakeWriteCloser with Write=nil, Close=err; verifies
  temp dir cleanup
- write error — fakeWriteCloser with Write=err (symmetric to the
  existing errReader test, confirms error wrapping is the same
  regardless of which side of io.Copy fails)

TestRunUpload_stdin_read_error exercises the runUpload stdinErr
branch end-to-end by injecting errReader as deps.stdin and asserting
PushAttachments is never called.

## Coverage

- files.go: 89.13% → 100% (materializeStdin + materializeStdinWith
  both at 100%)
- run.go: 97.08% → 97.81% (runUpload 97.6% → 98.8%; remaining 1.2%
  is a pre-existing json.Encode error branch from PR #18, not mine)
- internal/cli total: 96.18% → 98.1%, above the pre-PR baseline
  of 97.69%
… struct

Two changes from gemini-code-assist review on #20:

## validateName: reject Windows-reserved characters

Previous validation only blocked Unix path separators ('/', '\'),
but the docstring promised "legal on every common filesystem".
Windows also reserves '<', '>', ':', '"', '|', '?', '*' in file
names — uploads containing those characters would round-trip fine
on Linux/macOS but corrupt a Windows checkout. Block them here so
the error surfaces at `gh attach --name` time with a clear message
instead of at `git clone` time on a Windows box.

Added 7 new subtest cases covering each rejected character. The
set lives in a `nameReservedChars` const so the intent is explicit
and the reference to the MSDN naming-conventions doc sits next to
the constant that enforces it.

## runUpload: uploadOptions struct

runUpload had grown to 11 positional parameters, which was hard to
read at the call site and forced every test to pass `""`/`false`
for unused fields. Introduce an `uploadOptions` struct bundling the
parsed-flag state (number, filePaths, title, postComment,
repoOverride, key, name, asJSON). The single production caller in
runWithDeps now builds a labeled literal — no more guessing which
positional is which.

Function body reads `opts.X` directly, with the exception of
`number` which is copied into a local because it's mutated by PR
auto-detection. No semantic change — this is a pure readability
refactor.

All 23 test call sites updated; many become shorter because
zero-valued fields can be omitted from the struct literal. Tests
still green, coverage unchanged at 98.1%, golangci-lint 0 issues.
@fank
Copy link
Copy Markdown
Member Author

fank commented Apr 11, 2026

Addressed both review comments in d3ff5a8:

  1. validateName — Windows-reserved characters — now rejects <, >, :, ", |, ?, * in addition to / and \, matching the docstring's "legal on every common filesystem" promise. The rejected set lives in a nameReservedChars constant with an MSDN reference. Added 7 new test cases, one per character.

  2. runUploaduploadOptions struct — bundled the 8 parsed-flag parameters into a struct, so the signature is now runUpload(opts uploadOptions, stdout, stderr io.Writer, deps runDeps). The production caller in runWithDeps builds a labeled literal; test call sites become shorter because zero-valued fields can be omitted. The one local I kept is number, since it's mutated by PR auto-detection.

Tests + vet + lint all green, coverage unchanged at 98.1%.

@github-actions
Copy link
Copy Markdown

Merging this branch will increase overall coverage

Impacted Packages Coverage Δ 🤖
github.com/enthus-appdev/gh-attach/internal/cli 98.11% (+0.42%) 👍

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/enthus-appdev/gh-attach/internal/cli/delete.go 98.59% (ø) 71 70 1
github.com/enthus-appdev/gh-attach/internal/cli/files.go 100.00% (ø) 48 (+31) 48 (+31) 0
github.com/enthus-appdev/gh-attach/internal/cli/run.go 97.84% (+0.52%) 139 (+27) 136 (+27) 3 👍

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

Changed unit test files

  • github.com/enthus-appdev/gh-attach/internal/cli/delete_test.go
  • github.com/enthus-appdev/gh-attach/internal/cli/files_test.go
  • github.com/enthus-appdev/gh-attach/internal/cli/list_test.go
  • github.com/enthus-appdev/gh-attach/internal/cli/run_test.go

@fank fank merged commit db371b8 into main Apr 11, 2026
3 checks passed
@fank fank deleted the claude/stdin-input branch April 11, 2026 10:19
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.

1 participant