From fc85c85055798b301a61b5b2493f301fa64594ed Mon Sep 17 00:00:00 2001 From: Hiroaki KAWAI Date: Thu, 20 Aug 2026 19:01:48 +0900 Subject: [PATCH 1/8] Add unified page and attachment filter --- README.md | 74 ++++++++-- filter.go | 139 +++++++++++++++++++ internal/attachment/cache.go | 133 ++++++++++++++++++ internal/attachment/filter.go | 204 ++++++++++++++++++++++++++++ internal/attachment/pointer.go | 144 ++++++++++++++++++++ internal/attachment/pointer_test.go | 183 +++++++++++++++++++++++++ main.go | 55 ++++++-- main_test.go | 134 ++++++++++++++++++ 8 files changed, 1039 insertions(+), 27 deletions(-) create mode 100644 filter.go create mode 100644 internal/attachment/cache.go create mode 100644 internal/attachment/filter.go create mode 100644 internal/attachment/pointer.go create mode 100644 internal/attachment/pointer_test.go diff --git a/README.md b/README.md index 9a9bdb1..d9b9c97 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # git-confluence -`git-confluence` is a Git clean/smudge filter that converts between -Confluence storage XML and Markdown. +`git-confluence` is a unified Git filter for Confluence pages and attachments. +It converts page bodies between Confluence storage XML and Markdown and +materializes attachment pointer files through the Confluence download API. It keeps Confluence storage XML as the content stored in Git while exposing the same files as Markdown in the working tree. This lets Confluence keep the XML @@ -24,9 +25,11 @@ Git remote. Page bodies are stored in Git as Confluence storage XML. 5. The clean filter converts Markdown back to Confluence storage XML. 6. `git-remote-confluence` pushes the committed XML to existing Confluence page bodies. -This repository does not call the Confluence API by itself. It reads storage XML -or Markdown from standard input, converts it, and writes the result to standard -output. +Attachment pointers remain small text files when the filter is not configured, +the PAT is unavailable, or smudge is explicitly skipped. With the filter +configured, checkout downloads attachment bytes into a local Git-specific cache +and exposes them at their normal paths. Attachment changes are read-only until +upload support is implemented. ## Storage And Working Formats @@ -39,6 +42,14 @@ content shown in the working tree. | Working tree after checkout | Markdown | | Index after `git add` | Confluence storage XML | +For attachments: + +| Location | Format | +| --- | --- | +| Git blob | Confluence attachment pointer | +| Working tree after checkout | Attachment bytes, or pointer when unavailable/skipped | +| Index after `git add` | Original attachment pointer | + This split preserves storage XML for Confluence synchronization while allowing people to edit Markdown. @@ -94,26 +105,32 @@ git-confluence version ## Filter Configuration -Place this repository wherever you want, then configure the filter in the -repository that contains Confluence pages: +Configure the unified page and attachment filter once for the current user: ```sh -git config filter.confluence-storage.clean "/path/to/git-confluence/git-confluence clean" -git config filter.confluence-storage.smudge "/path/to/git-confluence/git-confluence smudge" -git config filter.confluence-storage.required true +git confluence install --global ``` +`install` registers the `filter.confluence` clean/smudge commands in Git +configuration; it does not install the `git-confluence` executable or download +attachments. + +Use `git confluence install --local` after a `--no-checkout` clone when the +configuration should apply to only one repository. + Limit the target files with `.gitattributes`: ```gitattributes -pages/**/*.md filter=confluence-storage diff=markdown +*.md filter=confluence diff=markdown +**/attachments/** filter=confluence -text ``` Repositories created by `git-remote-confluence` include this imported `.gitattributes` entry: ```gitattributes -*.md filter=confluence-storage diff=markdown +*.md filter=confluence diff=markdown +**/attachments/** filter=confluence -text ``` If you want the first checkout during clone to produce Markdown, configure the @@ -125,9 +142,7 @@ CONFLUENCE_PAT=... git clone --no-checkout \ 'confluence::https://confluence.example.com/pages/viewpage.action?pageId=123456789' \ pages cd pages -git config filter.confluence-storage.clean "/path/to/git-confluence/git-confluence clean" -git config filter.confluence-storage.smudge "/path/to/git-confluence/git-confluence smudge" -git config filter.confluence-storage.required true +git confluence install --local git checkout ``` @@ -139,6 +154,22 @@ CONFLUENCE_PAT=... git clone \ 'confluence::https://confluence.example.com/pages/viewpage.action?pageId=123456789' ``` +Keep attachment pointers while still converting page XML to Markdown: + +```sh +GIT_CONFLUENCE_SKIP_SMUDGE=1 git checkout +``` + +Materialize all attachments, or selected paths, after checkout: + +```sh +git confluence pull +git confluence pull 123456789/attachments/diagram.png +``` + +`pull` enables the filter locally, refuses to overwrite attachment paths with +local changes, and reports an error if an attachment could not be materialized. + ## Direct Use You can also run the converter directly instead of using it as a Git filter: @@ -151,6 +182,19 @@ go run . clean < page.md > page.xml `smudge` converts Confluence storage XML to Markdown. `clean` converts Markdown to Confluence storage XML. +## Attachment Authentication And Cache + +Attachment download uses the first PAT found in `CONFLUENCE_PAT`, +`GIT_REMOTE_CONFLUENCE_PAT`, `confluence.pat`, or +`remote.confluence.pat`. Download URLs are constrained to the Confluence origin +recorded by the pointer. + +Downloaded objects and clean-filter reverse mappings are stored below +`.git/confluence`. The cache avoids downloading the same pointer again and lets +the clean filter restore an unchanged attachment to its canonical pointer. +Unknown attachment bytes are rejected as read-only rather than accidentally +being committed as large Git blobs. + ## Input Size And Recursion Depth The filter reads up to 64 MiB by default. Set a byte limit when converting a diff --git a/filter.go b/filter.go new file mode 100644 index 0000000..949351e --- /dev/null +++ b/filter.go @@ -0,0 +1,139 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/hkwi/git-confluence/internal/attachment" + "github.com/hkwi/git-confluence/internal/confluence" +) + +func filterClean(path string, input io.Reader, output io.Writer, maxInput int64, maxDepth int) error { + if isAttachmentPath(path) { + return attachment.Clean(input, path, output) + } + data, err := readLimitedInput(input, maxInput) + if err != nil { + return err + } + storage, err := confluence.MarkdownToStorageWithMaxDepth(string(data), maxDepth) + if err != nil { + return err + } + _, err = io.WriteString(output, storage) + return err +} + +func filterSmudge(path string, input io.Reader, output, errorOutput io.Writer, maxInput int64, maxDepth int) error { + data, err := readLimitedInput(input, maxInput) + if err != nil { + return err + } + if isAttachmentPath(path) || attachment.IsPointer(data) { + return attachment.Smudge(data, path, output, errorOutput) + } + markdown, err := confluence.StorageToMarkdownWithMaxDepth(string(data), maxDepth) + if err != nil { + return err + } + _, err = io.WriteString(output, markdown) + return err +} + +func isAttachmentPath(path string) bool { + path = "/" + strings.Trim(filepath.ToSlash(path), "/") + "/" + return strings.Contains(path, "/attachments/") +} + +func installFilter(args []string) error { + scope := "--global" + if len(args) > 1 { + return fmt.Errorf("install accepts at most one scope") + } + if len(args) == 1 { + scope = args[0] + } + if scope != "--global" && scope != "--local" { + return fmt.Errorf("install scope must be --global or --local") + } + settings := [][2]string{ + {"filter.confluence.clean", "git-confluence filter-clean %f"}, + {"filter.confluence.smudge", "git-confluence filter-smudge %f"}, + {"filter.confluence.required", "true"}, + } + for _, setting := range settings { + cmd := exec.Command("git", "config", scope, setting[0], setting[1]) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git config %s: %w: %s", setting[0], err, strings.TrimSpace(string(output))) + } + } + return nil +} + +func pullFiles(paths []string) error { + if err := installFilter([]string{"--local"}); err != nil { + return err + } + if len(paths) == 0 { + output, err := exec.Command("git", "ls-files", "-z").Output() + if err != nil { + return fmt.Errorf("list tracked files: %w", err) + } + for _, path := range strings.Split(string(output), "\x00") { + if path != "" && isAttachmentPath(path) { + paths = append(paths, path) + } + } + } + if len(paths) == 0 { + return nil + } + pathspecs := make([]string, 0, len(paths)) + for index, path := range paths { + clean := filepath.Clean(path) + if clean != path || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || !isAttachmentPath(clean) { + return fmt.Errorf("%s is not an attachment path", path) + } + paths[index] = clean + pathspecs = append(pathspecs, ":(literal)"+filepath.ToSlash(clean)) + } + diffArgs := append([]string{"diff", "--quiet", "--"}, pathspecs...) + if err := exec.Command("git", diffArgs...).Run(); err != nil { + return fmt.Errorf("attachment working files have local changes; refusing to overwrite") + } + cachedDiffArgs := append([]string{"diff", "--cached", "--quiet", "HEAD", "--"}, pathspecs...) + if err := exec.Command("git", cachedDiffArgs...).Run(); err != nil { + return fmt.Errorf("attachment index entries have staged changes; refusing to overwrite") + } + for _, path := range paths { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("prepare attachment %s for materialization: %w", path, err) + } + } + const batchSize = 100 + for start := 0; start < len(paths); start += batchSize { + end := start + batchSize + if end > len(paths) { + end = len(paths) + } + args := append([]string{"checkout", "HEAD", "--"}, pathspecs[start:end]...) + cmd := exec.Command("git", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("materialize attachments: %w: %s", err, strings.TrimSpace(string(output))) + } + } + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("verify materialized attachment %s: %w", path, err) + } + if attachment.IsPointer(data) { + return fmt.Errorf("attachment %s was not materialized; configure a Confluence PAT or check the download error", path) + } + } + return nil +} diff --git a/internal/attachment/cache.go b/internal/attachment/cache.go new file mode 100644 index 0000000..56fbd31 --- /dev/null +++ b/internal/attachment/cache.go @@ -0,0 +1,133 @@ +package attachment + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const cacheDirEnv = "GIT_CONFLUENCE_CACHE_DIR" + +type cache struct { + root string +} + +func openCache() (cache, error) { + root := os.Getenv(cacheDirEnv) + if root == "" { + output, err := exec.Command("git", "rev-parse", "--git-path", "confluence").Output() + if err != nil { + return cache{}, fmt.Errorf("locate Git Confluence cache: %w", err) + } + root = strings.TrimSpace(string(output)) + } + if root == "" { + return cache{}, fmt.Errorf("Git Confluence cache path is empty") + } + return cache{root: root}, nil +} + +func (c cache) lookupPointer(pointer []byte) (string, bool) { + data, err := os.ReadFile(filepath.Join(c.root, "by-pointer", PointerOID(pointer))) + if err != nil { + return "", false + } + oid := strings.TrimSpace(string(data)) + if !validOID(oid) { + return "", false + } + path := filepath.Join(c.root, "objects", oid) + if _, err := os.Stat(path); err != nil { + return "", false + } + return path, true +} + +func (c cache) pointerForContent(oid, worktreePath string) ([]byte, bool) { + if !validOID(oid) { + return nil, false + } + data, err := os.ReadFile(filepath.Join(c.root, "by-content", oid, pathOID(worktreePath))) + if err != nil || !IsPointer(data) { + return nil, false + } + return data, true +} + +func (c cache) store(tempPath, contentOID, worktreePath string, pointer []byte) (string, error) { + for _, dir := range []string{"objects", "by-pointer", "by-content"} { + if err := os.MkdirAll(filepath.Join(c.root, dir), 0o700); err != nil { + return "", err + } + } + objectPath := filepath.Join(c.root, "objects", contentOID) + if err := os.Rename(tempPath, objectPath); err != nil { + if _, statErr := os.Stat(objectPath); statErr != nil { + return "", err + } + _ = os.Remove(tempPath) + } + if err := c.remember(contentOID, worktreePath, pointer); err != nil { + return "", err + } + return objectPath, nil +} + +func (c cache) remember(contentOID, worktreePath string, pointer []byte) error { + contentDir := filepath.Join(c.root, "by-content", contentOID) + if err := os.MkdirAll(contentDir, 0o700); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(c.root, "by-pointer"), 0o700); err != nil { + return err + } + if err := writeAtomic(filepath.Join(contentDir, pathOID(worktreePath)), pointer); err != nil { + return err + } + return writeAtomic(filepath.Join(c.root, "by-pointer", PointerOID(pointer)), []byte(contentOID+"\n")) +} + +func pathOID(worktreePath string) string { + hash := sha256.Sum256([]byte(filepath.ToSlash(worktreePath))) + return hex.EncodeToString(hash[:]) +} + +func (c cache) newTemp() (*os.File, error) { + if err := os.MkdirAll(c.root, 0o700); err != nil { + return nil, err + } + return os.CreateTemp(c.root, "download-*") +} + +func writeAtomic(path string, data []byte) error { + temp, err := os.CreateTemp(filepath.Dir(path), "mapping-*") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(tempPath, path) +} + +func validOID(value string) bool { + if len(value) != sha256.Size*2 { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} diff --git a/internal/attachment/filter.go b/internal/attachment/filter.go new file mode 100644 index 0000000..b8847ce --- /dev/null +++ b/internal/attachment/filter.go @@ -0,0 +1,204 @@ +package attachment + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + skipSmudgeEnv = "GIT_CONFLUENCE_SKIP_SMUDGE" + maxAttachmentEnv = "GIT_CONFLUENCE_MAX_ATTACHMENT_BYTES" + defaultMaxAttachmentBytes = int64(1 << 30) + maxPointerBytes = int64(64 << 10) +) + +func Smudge(pointerData []byte, worktreePath string, output, errorOutput io.Writer) error { + pointer, err := ParsePointer(pointerData) + if err != nil { + return err + } + canonical := pointer.Canonical() + if os.Getenv(skipSmudgeEnv) != "" { + _, err = output.Write(canonical) + return err + } + cache, err := openCache() + if err != nil { + return err + } + if path, ok := cache.lookupPointer(canonical); ok { + if err := cache.remember(filepath.Base(path), worktreePath, canonical); err != nil { + return err + } + return copyFile(path, output) + } + pat := resolvePAT() + if pat == "" { + fmt.Fprintln(errorOutput, "git-confluence: Confluence PAT is not configured; leaving attachment pointer in working tree") + _, err = output.Write(canonical) + return err + } + path, err := download(cache, pointer, canonical, worktreePath, pat) + if err != nil { + fmt.Fprintf(errorOutput, "git-confluence: %v; leaving attachment pointer in working tree\n", err) + _, writeErr := output.Write(canonical) + return writeErr + } + return copyFile(path, output) +} + +func Clean(input io.Reader, worktreePath string, output io.Writer) error { + maxBytes, err := maxAttachmentBytes() + if err != nil { + return err + } + hash := sha256.New() + buffer := make([]byte, 128<<10) + prefix := make([]byte, 0, maxPointerBytes) + var total int64 + for { + read, readErr := input.Read(buffer) + if read > 0 { + total += int64(read) + if total > maxBytes { + return fmt.Errorf("attachment exceeds %d bytes; set %s to a larger byte count", maxBytes, maxAttachmentEnv) + } + _, _ = hash.Write(buffer[:read]) + if int64(len(prefix)) < maxPointerBytes { + remaining := int(maxPointerBytes - int64(len(prefix))) + keep := read + if keep > remaining { + keep = remaining + } + prefix = append(prefix, buffer[:keep]...) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return readErr + } + } + if total <= maxPointerBytes && IsPointer(prefix) { + pointer, err := ParsePointer(prefix) + if err != nil { + return err + } + _, err = output.Write(pointer.Canonical()) + return err + } + cache, err := openCache() + if err != nil { + return err + } + oid := hex.EncodeToString(hash.Sum(nil)) + pointer, ok := cache.pointerForContent(oid, worktreePath) + if !ok { + return fmt.Errorf("attachment content is not a downloaded Confluence version; attachments are read-only") + } + _, err = output.Write(pointer) + return err +} + +func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat string) (string, error) { + downloadURL, err := pointer.DownloadURL() + if err != nil { + return "", err + } + request, err := http.NewRequest(http.MethodGet, downloadURL.String(), nil) + if err != nil { + return "", err + } + request.Header.Set("Authorization", "Bearer "+pat) + request.Header.Set("User-Agent", "git-confluence/dev") + client := &http.Client{Timeout: 5 * time.Minute} + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != downloadURL.Scheme || req.URL.Host != downloadURL.Host { + return fmt.Errorf("attachment redirect points outside Confluence origin") + } + return nil + } + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("download attachment %s version %d: %w", pointer.AttachmentID, pointer.AttachmentVersion, err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", fmt.Errorf("download attachment %s version %d: HTTP %d", pointer.AttachmentID, pointer.AttachmentVersion, response.StatusCode) + } + maxBytes, err := maxAttachmentBytes() + if err != nil { + return "", err + } + temp, err := cache.newTemp() + if err != nil { + return "", err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + hash := sha256.New() + written, copyErr := io.Copy(io.MultiWriter(temp, hash), io.LimitReader(response.Body, maxBytes+1)) + closeErr := temp.Close() + if copyErr != nil { + return "", copyErr + } + if closeErr != nil { + return "", closeErr + } + if written > maxBytes { + return "", fmt.Errorf("attachment exceeds %d bytes; set %s to a larger byte count", maxBytes, maxAttachmentEnv) + } + if pointer.Size > 0 && written != pointer.Size { + return "", fmt.Errorf("attachment size mismatch: pointer has %d bytes, downloaded %d", pointer.Size, written) + } + oid := hex.EncodeToString(hash.Sum(nil)) + return cache.store(tempPath, oid, worktreePath, canonical) +} + +func copyFile(path string, output io.Writer) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(output, bufio.NewReader(file)) + return err +} + +func resolvePAT() string { + for _, name := range []string{"CONFLUENCE_PAT", "GIT_REMOTE_CONFLUENCE_PAT"} { + if value := os.Getenv(name); value != "" { + return value + } + } + for _, key := range []string{"confluence.pat", "remote.confluence.pat"} { + output, err := exec.Command("git", "config", "--get", key).Output() + if err == nil && strings.TrimSpace(string(output)) != "" { + return strings.TrimSpace(string(output)) + } + } + return "" +} + +func maxAttachmentBytes() (int64, error) { + value := os.Getenv(maxAttachmentEnv) + if value == "" { + return defaultMaxAttachmentBytes, nil + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 1 { + return 0, fmt.Errorf("%s must be a positive byte count", maxAttachmentEnv) + } + return parsed, nil +} diff --git a/internal/attachment/pointer.go b/internal/attachment/pointer.go new file mode 100644 index 0000000..9db6d88 --- /dev/null +++ b/internal/attachment/pointer.go @@ -0,0 +1,144 @@ +package attachment + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/url" + "strconv" + "strings" +) + +const SpecURL = "https://github.com/hkwi/git-remote-confluence/spec/attachment/v1" + +type Pointer struct { + SourceURL string + PageID string + AttachmentID string + AttachmentVersion int + Filename string + Size int64 + MediaType string + DownloadPath string +} + +func IsPointer(data []byte) bool { + first, _, _ := bytes.Cut(data, []byte{'\n'}) + return string(bytes.TrimSuffix(first, []byte{'\r'})) == "version "+SpecURL +} + +func ParsePointer(data []byte) (Pointer, error) { + if !IsPointer(data) { + return Pointer{}, fmt.Errorf("not a Confluence attachment pointer") + } + values := map[string]string{} + knownFields := map[string]bool{ + "version": true, + "source": true, + "page-id": true, + "attachment-id": true, + "attachment-version": true, + "filename": true, + "size": true, + "media-type": true, + "download-path": true, + } + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + for index, line := range lines { + if line == "" { + continue + } + key, value, ok := strings.Cut(line, " ") + if !ok || value == "" { + return Pointer{}, fmt.Errorf("invalid pointer line %d", index+1) + } + if !knownFields[key] { + return Pointer{}, fmt.Errorf("unknown pointer field %q", key) + } + if _, exists := values[key]; exists { + return Pointer{}, fmt.Errorf("duplicate pointer field %q", key) + } + values[key] = value + } + if values["version"] != SpecURL { + return Pointer{}, fmt.Errorf("unsupported attachment pointer version %q", values["version"]) + } + version, err := strconv.Atoi(values["attachment-version"]) + if err != nil || version <= 0 { + return Pointer{}, fmt.Errorf("invalid attachment-version %q", values["attachment-version"]) + } + size, err := strconv.ParseInt(values["size"], 10, 64) + if err != nil || size < 0 { + return Pointer{}, fmt.Errorf("invalid attachment size %q", values["size"]) + } + pointer := Pointer{ + SourceURL: values["source"], + PageID: values["page-id"], + AttachmentID: values["attachment-id"], + AttachmentVersion: version, + Filename: values["filename"], + Size: size, + MediaType: values["media-type"], + DownloadPath: values["download-path"], + } + if err := pointer.validate(); err != nil { + return Pointer{}, err + } + return pointer, nil +} + +func (p Pointer) Canonical() []byte { + var out strings.Builder + fmt.Fprintf(&out, "version %s\n", SpecURL) + fmt.Fprintf(&out, "source %s\n", strings.TrimRight(p.SourceURL, "/")) + fmt.Fprintf(&out, "page-id %s\n", p.PageID) + fmt.Fprintf(&out, "attachment-id %s\n", p.AttachmentID) + fmt.Fprintf(&out, "attachment-version %d\n", p.AttachmentVersion) + fmt.Fprintf(&out, "filename %s\n", p.Filename) + fmt.Fprintf(&out, "size %d\n", p.Size) + if p.MediaType != "" { + fmt.Fprintf(&out, "media-type %s\n", p.MediaType) + } + fmt.Fprintf(&out, "download-path %s\n", p.DownloadPath) + return []byte(out.String()) +} + +func (p Pointer) DownloadURL() (*url.URL, error) { + if err := p.validate(); err != nil { + return nil, err + } + base, _ := url.Parse(strings.TrimRight(p.SourceURL, "/") + "/") + path, _ := url.Parse(p.DownloadPath) + resolved := base.ResolveReference(path) + if resolved.Scheme != base.Scheme || resolved.Host != base.Host { + return nil, fmt.Errorf("attachment download URL points outside Confluence origin") + } + query := resolved.Query() + query.Set("version", strconv.Itoa(p.AttachmentVersion)) + resolved.RawQuery = query.Encode() + return resolved, nil +} + +func PointerOID(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} + +func (p Pointer) validate() error { + base, err := url.Parse(p.SourceURL) + if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" { + return fmt.Errorf("invalid Confluence source URL %q", p.SourceURL) + } + if p.PageID == "" || p.AttachmentID == "" || p.Filename == "" { + return fmt.Errorf("attachment pointer is missing identity fields") + } + if p.AttachmentVersion <= 0 || p.Size < 0 { + return fmt.Errorf("attachment pointer has invalid version or size") + } + download, err := url.Parse(p.DownloadPath) + if err != nil || download.IsAbs() || !strings.HasPrefix(download.Path, "/") { + return fmt.Errorf("invalid attachment download path %q", p.DownloadPath) + } + return nil +} diff --git a/internal/attachment/pointer_test.go b/internal/attachment/pointer_test.go new file mode 100644 index 0000000..6987d4b --- /dev/null +++ b/internal/attachment/pointer_test.go @@ -0,0 +1,183 @@ +package attachment + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestPointerCanonicalRoundTrip(t *testing.T) { + pointer := Pointer{ + SourceURL: "https://cf.example.test/wiki", + PageID: "123", + AttachmentID: "456", + AttachmentVersion: 7, + Filename: "diagram one.png", + Size: 42, + MediaType: "image/png", + DownloadPath: "/wiki/download/attachments/123/diagram%20one.png?api=v2", + } + data := pointer.Canonical() + parsed, err := ParsePointer(data) + if err != nil { + t.Fatal(err) + } + if parsed != pointer { + t.Fatalf("parsed pointer = %#v, want %#v", parsed, pointer) + } + downloadURL, err := parsed.DownloadURL() + if err != nil { + t.Fatal(err) + } + if got := downloadURL.String(); got != "https://cf.example.test/wiki/download/attachments/123/diagram%20one.png?api=v2&version=7" { + t.Fatalf("download URL = %q", got) + } +} + +func TestSmudgeDownloadsCachesAndCleanRestoresPointer(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/download/attachments/123/file.bin" || r.URL.Query().Get("version") != "2" { + t.Errorf("request URL = %s", r.URL.String()) + } + if r.Header.Get("Authorization") != "Bearer secret" { + t.Errorf("authorization = %q", r.Header.Get("Authorization")) + } + _, _ = w.Write([]byte("attachment data")) + })) + defer server.Close() + + t.Setenv(cacheDirEnv, t.TempDir()) + t.Setenv("CONFLUENCE_PAT", "secret") + pointer := Pointer{ + SourceURL: server.URL, + PageID: "123", + AttachmentID: "456", + AttachmentVersion: 2, + Filename: "file.bin", + Size: int64(len("attachment data")), + MediaType: "application/octet-stream", + DownloadPath: "/download/attachments/123/file.bin", + }.Canonical() + + for attempt := 0; attempt < 2; attempt++ { + var output bytes.Buffer + if err := Smudge(pointer, "123/attachments/file.bin", &output, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if output.String() != "attachment data" { + t.Fatalf("smudge output = %q", output.String()) + } + } + t.Setenv("CONFLUENCE_PAT", "") + t.Setenv("GIT_REMOTE_CONFLUENCE_PAT", "") + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + var cachedOutput bytes.Buffer + if err := Smudge(pointer, "123/attachments/file.bin", &cachedOutput, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if cachedOutput.String() != "attachment data" { + t.Fatalf("cached smudge output = %q", cachedOutput.String()) + } + if requests.Load() != 1 { + t.Fatalf("download requests = %d, want 1", requests.Load()) + } + + var cleaned bytes.Buffer + if err := Clean(strings.NewReader("attachment data"), "123/attachments/file.bin", &cleaned); err != nil { + t.Fatal(err) + } + if !bytes.Equal(cleaned.Bytes(), pointer) { + t.Fatalf("cleaned pointer:\n%s\nwant:\n%s", cleaned.Bytes(), pointer) + } +} + +func TestSmudgeWithoutPATLeavesPointer(t *testing.T) { + t.Setenv(cacheDirEnv, t.TempDir()) + t.Setenv("CONFLUENCE_PAT", "") + t.Setenv("GIT_REMOTE_CONFLUENCE_PAT", "") + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + pointer := Pointer{ + SourceURL: "https://cf.example.test", + PageID: "1", + AttachmentID: "2", + AttachmentVersion: 3, + Filename: "file.bin", + DownloadPath: "/download/file.bin", + }.Canonical() + var output bytes.Buffer + var errorOutput bytes.Buffer + if err := Smudge(pointer, "1/attachments/file.bin", &output, &errorOutput); err != nil { + t.Fatal(err) + } + if !bytes.Equal(output.Bytes(), pointer) { + t.Fatalf("smudge output:\n%s", output.Bytes()) + } + if !strings.Contains(errorOutput.String(), "leaving attachment pointer") { + t.Fatalf("stderr = %q", errorOutput.String()) + } +} + +func TestCleanRejectsUnknownAttachment(t *testing.T) { + t.Setenv(cacheDirEnv, t.TempDir()) + err := Clean(strings.NewReader("locally modified data"), "1/attachments/file.bin", &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "read-only") { + t.Fatalf("Clean error = %v", err) + } +} + +func TestCleanDistinguishesSameContentAtDifferentPaths(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = w.Write([]byte("shared bytes")) + })) + defer server.Close() + + t.Setenv(cacheDirEnv, t.TempDir()) + t.Setenv("CONFLUENCE_PAT", "secret") + pointers := []Pointer{ + {SourceURL: server.URL, PageID: "1", AttachmentID: "11", AttachmentVersion: 1, Filename: "a.bin", Size: 12, DownloadPath: "/a.bin"}, + {SourceURL: server.URL, PageID: "2", AttachmentID: "22", AttachmentVersion: 1, Filename: "b.bin", Size: 12, DownloadPath: "/b.bin"}, + } + paths := []string{"1/attachments/a.bin", "2/attachments/b.bin"} + for index, pointer := range pointers { + var output bytes.Buffer + if err := Smudge(pointer.Canonical(), paths[index], &output, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + } + for index, pointer := range pointers { + var cleaned bytes.Buffer + if err := Clean(strings.NewReader("shared bytes"), paths[index], &cleaned); err != nil { + t.Fatal(err) + } + if !bytes.Equal(cleaned.Bytes(), pointer.Canonical()) { + t.Fatalf("path %s cleaned to:\n%s\nwant:\n%s", paths[index], cleaned.Bytes(), pointer.Canonical()) + } + } + if requests.Load() != 2 { + t.Fatalf("download requests = %d, want 2", requests.Load()) + } +} + +func TestParsePointerRejectsUnknownField(t *testing.T) { + pointer := Pointer{ + SourceURL: "https://cf.example.test", + PageID: "1", + AttachmentID: "2", + AttachmentVersion: 3, + Filename: "file.bin", + DownloadPath: "/download/file.bin", + }.Canonical() + pointer = append(pointer, []byte("unexpected value\n")...) + if _, err := ParsePointer(pointer); err == nil || !strings.Contains(err.Error(), "unknown pointer field") { + t.Fatalf("ParsePointer error = %v", err) + } +} diff --git a/main.go b/main.go index 2e216b6..f241d76 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,7 @@ var ( ) func main() { - if len(os.Args) != 2 { + if len(os.Args) < 2 { usage() os.Exit(2) } @@ -33,6 +33,18 @@ func main() { case "version", "--version", "-version": fmt.Print(versionOutput()) return + case "install": + if err := installFilter(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) + os.Exit(1) + } + return + case "pull": + if err := pullFiles(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) + os.Exit(1) + } + return } maxInput, err := maxInputBytes() @@ -47,27 +59,41 @@ func main() { os.Exit(2) } - input, err := readLimitedInput(os.Stdin, maxInput) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) - os.Exit(1) - } - switch os.Args[1] { case "clean": + input, err := readLimitedInput(os.Stdin, maxInput) + if err != nil { + fail(err) + } storage, err := confluence.MarkdownToStorageWithMaxDepth(string(input), maxDepth) if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) - os.Exit(1) + fail(err) } fmt.Print(storage) case "smudge": + input, err := readLimitedInput(os.Stdin, maxInput) + if err != nil { + fail(err) + } markdown, err := confluence.StorageToMarkdownWithMaxDepth(string(input), maxDepth) if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) - os.Exit(1) + fail(err) } fmt.Print(markdown) + case "filter-clean": + if len(os.Args) != 3 { + fail(fmt.Errorf("filter-clean requires a pathname")) + } + if err := filterClean(os.Args[2], os.Stdin, os.Stdout, maxInput, maxDepth); err != nil { + fail(err) + } + case "filter-smudge": + if len(os.Args) != 3 { + fail(fmt.Errorf("filter-smudge requires a pathname")) + } + if err := filterSmudge(os.Args[2], os.Stdin, os.Stdout, os.Stderr, maxInput, maxDepth); err != nil { + fail(err) + } default: usage() os.Exit(2) @@ -75,7 +101,12 @@ func main() { } func usage() { - fmt.Fprintf(os.Stderr, "usage: %s clean|smudge|version\n", appName) + fmt.Fprintf(os.Stderr, "usage: %s clean|smudge|filter-clean |filter-smudge |install [--global|--local]|pull [path...]|version\n", appName) +} + +func fail(err error) { + fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) + os.Exit(1) } func versionOutput() string { diff --git a/main_test.go b/main_test.go index f628e60..2e9c099 100644 --- a/main_test.go +++ b/main_test.go @@ -1,8 +1,16 @@ package main import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" "strings" "testing" + + "github.com/hkwi/git-confluence/internal/attachment" ) func TestReadLimitedInputAcceptsInputAtLimit(t *testing.T) { @@ -59,3 +67,129 @@ func TestVersionOutput(t *testing.T) { t.Fatalf("versionOutput() = %q, want %q", got, want) } } + +func TestUnifiedFilterMaterializesAttachmentAndCleansBackToPointer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/download/file.bin" || r.URL.Query().Get("version") != "3" { + t.Errorf("request URL = %s", r.URL.String()) + } + _, _ = w.Write([]byte("attachment bytes")) + })) + defer server.Close() + + tmp := t.TempDir() + repo := filepath.Join(tmp, "repo") + binDir := filepath.Join(tmp, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + filterPath := filepath.Join(binDir, "git-confluence") + build := exec.Command("go", "build", "-o", filterPath, ".") + build.Env = append(os.Environ(), "GOCACHE="+filepath.Join(tmp, "gocache")) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, output) + } + if err := os.MkdirAll(filepath.Join(repo, "1", "attachments"), 0o755); err != nil { + t.Fatal(err) + } + pointer := attachment.Pointer{ + SourceURL: server.URL, + PageID: "1", + AttachmentID: "2", + AttachmentVersion: 3, + Filename: "file.bin", + Size: int64(len("attachment bytes")), + DownloadPath: "/download/file.bin", + }.Canonical() + attachmentPath := filepath.Join(repo, "1", "attachments", "file.bin") + if err := os.WriteFile(attachmentPath, pointer, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".gitattributes"), []byte("**/attachments/** filter=confluence -text\n"), 0o644); err != nil { + t.Fatal(err) + } + + env := append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "CONFLUENCE_PAT=secret", + "GIT_CONFLUENCE_CACHE_DIR="+filepath.Join(tmp, "cache"), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_NOSYSTEM=1", + ) + runGit(t, repo, env, "init") + runGit(t, repo, env, "config", "user.name", "Test") + runGit(t, repo, env, "config", "user.email", "test@example.invalid") + install := exec.Command(filterPath, "install", "--local") + install.Dir = repo + install.Env = env + if output, err := install.CombinedOutput(); err != nil { + t.Fatalf("install filter: %v\n%s", err, output) + } + runGit(t, repo, env, "add", ".") + runGit(t, repo, env, "commit", "-m", "pointer") + if err := os.Remove(attachmentPath); err != nil { + t.Fatal(err) + } + runGit(t, repo, env, "checkout", "HEAD", "--", "1/attachments/file.bin") + materialized, err := os.ReadFile(attachmentPath) + if err != nil { + t.Fatal(err) + } + if string(materialized) != "attachment bytes" { + t.Fatalf("materialized attachment = %q", materialized) + } + if status := runGit(t, repo, env, "status", "--short"); status != "" { + attr := runGit(t, repo, env, "check-attr", "filter", "--", "1/attachments/file.bin") + cleanConfig := runGit(t, repo, env, "config", "--get", "filter.confluence.clean") + indexOID := runGit(t, repo, env, "rev-parse", ":1/attachments/file.bin") + cleanOID := runGit(t, repo, env, "hash-object", "--path=1/attachments/file.bin", "1/attachments/file.bin") + t.Fatalf("worktree status after smudge = %q\nattr: %s\nclean: %s\nindex: %s\ncleaned: %s", status, attr, cleanConfig, indexOID, cleanOID) + } + if err := os.Remove(attachmentPath); err != nil { + t.Fatal(err) + } + skipEnv := append(append([]string{}, env...), "GIT_CONFLUENCE_SKIP_SMUDGE=1") + runGit(t, repo, skipEnv, "checkout", "HEAD", "--", "1/attachments/file.bin") + pointerData, err := os.ReadFile(attachmentPath) + if err != nil { + t.Fatal(err) + } + if !attachment.IsPointer(pointerData) { + t.Fatalf("skip-smudge checkout = %q, want pointer", pointerData) + } + pull := exec.Command(filterPath, "pull", "1/attachments/file.bin") + pull.Dir = repo + pull.Env = env + if output, err := pull.CombinedOutput(); err != nil { + t.Fatalf("pull attachment: %v\n%s", err, output) + } + materialized, err = os.ReadFile(attachmentPath) + if err != nil { + t.Fatal(err) + } + if string(materialized) != "attachment bytes" { + t.Fatalf("pulled attachment = %q", materialized) + } + if err := os.WriteFile(attachmentPath, []byte("locally changed"), 0o644); err != nil { + t.Fatal(err) + } + add := exec.Command("git", "add", "1/attachments/file.bin") + add.Dir = repo + add.Env = env + output, err := add.CombinedOutput() + if err == nil || !bytes.Contains(output, []byte("read-only")) { + t.Fatalf("git add modified attachment error = %v\n%s", err, output) + } +} + +func runGit(t *testing.T, dir string, env []string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = env + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) + } + return strings.TrimSpace(string(output)) +} From 5eec47ae8d903a569dd6ad75871265472e762073 Mon Sep 17 00:00:00 2001 From: Hiroaki KAWAI Date: Thu, 20 Aug 2026 19:35:46 +0900 Subject: [PATCH 2/8] Include attachment path in smudge errors --- internal/attachment/filter.go | 2 +- internal/attachment/pointer_test.go | 31 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/attachment/filter.go b/internal/attachment/filter.go index b8847ce..c10b556 100644 --- a/internal/attachment/filter.go +++ b/internal/attachment/filter.go @@ -50,7 +50,7 @@ func Smudge(pointerData []byte, worktreePath string, output, errorOutput io.Writ } path, err := download(cache, pointer, canonical, worktreePath, pat) if err != nil { - fmt.Fprintf(errorOutput, "git-confluence: %v; leaving attachment pointer in working tree\n", err) + fmt.Fprintf(errorOutput, "git-confluence: attachment %q: %v; leaving attachment pointer in working tree\n", worktreePath, err) _, writeErr := output.Write(canonical) return writeErr } diff --git a/internal/attachment/pointer_test.go b/internal/attachment/pointer_test.go index 6987d4b..09d4463 100644 --- a/internal/attachment/pointer_test.go +++ b/internal/attachment/pointer_test.go @@ -124,6 +124,37 @@ func TestSmudgeWithoutPATLeavesPointer(t *testing.T) { } } +func TestSmudgeDownloadErrorIdentifiesWorktreePath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("wrong size")) + })) + defer server.Close() + + t.Setenv(cacheDirEnv, t.TempDir()) + t.Setenv("CONFLUENCE_PAT", "secret") + pointer := Pointer{ + SourceURL: server.URL, + PageID: "1", + AttachmentID: "2", + AttachmentVersion: 3, + Filename: "file.bin", + Size: 42, + DownloadPath: "/download/file.bin", + }.Canonical() + const worktreePath = "1/attachments/file.bin" + var output bytes.Buffer + var errorOutput bytes.Buffer + if err := Smudge(pointer, worktreePath, &output, &errorOutput); err != nil { + t.Fatal(err) + } + if !bytes.Equal(output.Bytes(), pointer) { + t.Fatalf("smudge output:\n%s", output.Bytes()) + } + if !strings.Contains(errorOutput.String(), `attachment "`+worktreePath+`": attachment size mismatch`) { + t.Fatalf("stderr = %q", errorOutput.String()) + } +} + func TestCleanRejectsUnknownAttachment(t *testing.T) { t.Setenv(cacheDirEnv, t.TempDir()) err := Clean(strings.NewReader("locally modified data"), "1/attachments/file.bin", &bytes.Buffer{}) From d4acd3b2adc837ff4a57051d7af272159879ddb0 Mon Sep 17 00:00:00 2001 From: Hiroaki KAWAI Date: Thu, 20 Aug 2026 19:45:00 +0900 Subject: [PATCH 3/8] Treat attachment size mismatches as warnings --- internal/attachment/filter.go | 6 +++--- internal/attachment/pointer_test.go | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/attachment/filter.go b/internal/attachment/filter.go index c10b556..dd6358f 100644 --- a/internal/attachment/filter.go +++ b/internal/attachment/filter.go @@ -48,7 +48,7 @@ func Smudge(pointerData []byte, worktreePath string, output, errorOutput io.Writ _, err = output.Write(canonical) return err } - path, err := download(cache, pointer, canonical, worktreePath, pat) + path, err := download(cache, pointer, canonical, worktreePath, pat, errorOutput) if err != nil { fmt.Fprintf(errorOutput, "git-confluence: attachment %q: %v; leaving attachment pointer in working tree\n", worktreePath, err) _, writeErr := output.Write(canonical) @@ -111,7 +111,7 @@ func Clean(input io.Reader, worktreePath string, output io.Writer) error { return err } -func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat string) (string, error) { +func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat string, warningOutput io.Writer) (string, error) { downloadURL, err := pointer.DownloadURL() if err != nil { return "", err @@ -160,7 +160,7 @@ func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat return "", fmt.Errorf("attachment exceeds %d bytes; set %s to a larger byte count", maxBytes, maxAttachmentEnv) } if pointer.Size > 0 && written != pointer.Size { - return "", fmt.Errorf("attachment size mismatch: pointer has %d bytes, downloaded %d", pointer.Size, written) + fmt.Fprintf(warningOutput, "git-confluence: warning: attachment %q: pointer size is %d bytes, but downloaded content is %d bytes; using downloaded content\n", worktreePath, pointer.Size, written) } oid := hex.EncodeToString(hash.Sum(nil)) return cache.store(tempPath, oid, worktreePath, canonical) diff --git a/internal/attachment/pointer_test.go b/internal/attachment/pointer_test.go index 09d4463..bd9d1ae 100644 --- a/internal/attachment/pointer_test.go +++ b/internal/attachment/pointer_test.go @@ -124,7 +124,7 @@ func TestSmudgeWithoutPATLeavesPointer(t *testing.T) { } } -func TestSmudgeDownloadErrorIdentifiesWorktreePath(t *testing.T) { +func TestSmudgeSizeMismatchWarnsAndUsesDownloadedContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("wrong size")) })) @@ -147,10 +147,11 @@ func TestSmudgeDownloadErrorIdentifiesWorktreePath(t *testing.T) { if err := Smudge(pointer, worktreePath, &output, &errorOutput); err != nil { t.Fatal(err) } - if !bytes.Equal(output.Bytes(), pointer) { - t.Fatalf("smudge output:\n%s", output.Bytes()) + if output.String() != "wrong size" { + t.Fatalf("smudge output = %q", output.String()) } - if !strings.Contains(errorOutput.String(), `attachment "`+worktreePath+`": attachment size mismatch`) { + wantWarning := `git-confluence: warning: attachment "` + worktreePath + `": pointer size is 42 bytes, but downloaded content is 10 bytes; using downloaded content` + if !strings.Contains(errorOutput.String(), wantWarning) { t.Fatalf("stderr = %q", errorOutput.String()) } } From 4fb9416abc0e4c3101f24118ffdef38c985d0e58 Mon Sep 17 00:00:00 2001 From: Hiroaki KAWAI Date: Fri, 21 Aug 2026 11:53:45 +0900 Subject: [PATCH 4/8] Use YAML attachment pointers and logfmt logs --- README.md | 4 +- go.mod | 2 + go.sum | 2 + internal/attachment/filter.go | 12 ++- internal/attachment/pointer.go | 109 ++++++++++++---------------- internal/attachment/pointer_test.go | 38 ++++++---- internal/logging/logging.go | 20 +++++ internal/logging/logging_test.go | 15 ++++ main.go | 24 ++++-- main_test.go | 8 ++ 10 files changed, 144 insertions(+), 90 deletions(-) create mode 100644 go.sum create mode 100644 internal/logging/logging.go create mode 100644 internal/logging/logging_test.go diff --git a/README.md b/README.md index d9b9c97..269bd4b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Git remote. Page bodies are stored in Git as Confluence storage XML. 5. The clean filter converts Markdown back to Confluence storage XML. 6. `git-remote-confluence` pushes the committed XML to existing Confluence page bodies. -Attachment pointers remain small text files when the filter is not configured, +Attachment pointers remain small YAML files when the filter is not configured, the PAT is unavailable, or smudge is explicitly skipped. With the filter configured, checkout downloads attachment bytes into a local Git-specific cache and exposes them at their normal paths. Attachment changes are read-only until @@ -50,6 +50,8 @@ For attachments: | Working tree after checkout | Attachment bytes, or pointer when unavailable/skipped | | Index after `git add` | Original attachment pointer | +Attachment pointers use the `attachment/v1` YAML schema. + This split preserves storage XML for Confluence synchronization while allowing people to edit Markdown. diff --git a/go.mod b/go.mod index 35c1913..1889bf4 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/hkwi/git-confluence go 1.26 + +require go.yaml.in/yaml/v3 v3.0.5 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8675e7f --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= diff --git a/internal/attachment/filter.go b/internal/attachment/filter.go index dd6358f..6316114 100644 --- a/internal/attachment/filter.go +++ b/internal/attachment/filter.go @@ -13,6 +13,8 @@ import ( "strconv" "strings" "time" + + "github.com/hkwi/git-confluence/internal/logging" ) const ( @@ -44,13 +46,14 @@ func Smudge(pointerData []byte, worktreePath string, output, errorOutput io.Writ } pat := resolvePAT() if pat == "" { - fmt.Fprintln(errorOutput, "git-confluence: Confluence PAT is not configured; leaving attachment pointer in working tree") + logging.New(errorOutput).Warn("Confluence PAT is not configured; leaving attachment pointer in working tree", "app", "git-confluence") _, err = output.Write(canonical) return err } path, err := download(cache, pointer, canonical, worktreePath, pat, errorOutput) if err != nil { - fmt.Fprintf(errorOutput, "git-confluence: attachment %q: %v; leaving attachment pointer in working tree\n", worktreePath, err) + logging.New(errorOutput).Warn("attachment download failed; leaving attachment pointer in working tree", + "app", "git-confluence", "path", worktreePath, "error", err) _, writeErr := output.Write(canonical) return writeErr } @@ -112,6 +115,7 @@ func Clean(input io.Reader, worktreePath string, output io.Writer) error { } func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat string, warningOutput io.Writer) (string, error) { + logger := logging.New(warningOutput) downloadURL, err := pointer.DownloadURL() if err != nil { return "", err @@ -160,7 +164,9 @@ func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat return "", fmt.Errorf("attachment exceeds %d bytes; set %s to a larger byte count", maxBytes, maxAttachmentEnv) } if pointer.Size > 0 && written != pointer.Size { - fmt.Fprintf(warningOutput, "git-confluence: warning: attachment %q: pointer size is %d bytes, but downloaded content is %d bytes; using downloaded content\n", worktreePath, pointer.Size, written) + logger.Warn("attachment size differs from pointer; using downloaded content", + "app", "git-confluence", "path", worktreePath, + "pointer_size", pointer.Size, "downloaded_size", written) } oid := hex.EncodeToString(hash.Sum(nil)) return cache.store(tempPath, oid, worktreePath, canonical) diff --git a/internal/attachment/pointer.go b/internal/attachment/pointer.go index 9db6d88..4eb5988 100644 --- a/internal/attachment/pointer.go +++ b/internal/attachment/pointer.go @@ -1,13 +1,14 @@ package attachment import ( - "bytes" "crypto/sha256" "encoding/hex" "fmt" "net/url" "strconv" "strings" + + "go.yaml.in/yaml/v3" ) const SpecURL = "https://github.com/hkwi/git-remote-confluence/spec/attachment/v1" @@ -23,64 +24,42 @@ type Pointer struct { DownloadPath string } +type pointerYAML struct { + Version string `yaml:"version"` + SourceURL string `yaml:"source"` + PageID string `yaml:"page_id"` + AttachmentID string `yaml:"attachment_id"` + AttachmentVersion int `yaml:"attachment_version"` + Filename string `yaml:"filename"` + Size int64 `yaml:"size"` + MediaType string `yaml:"media_type,omitempty"` + DownloadPath string `yaml:"download_path"` +} + func IsPointer(data []byte) bool { - first, _, _ := bytes.Cut(data, []byte{'\n'}) - return string(bytes.TrimSuffix(first, []byte{'\r'})) == "version "+SpecURL + var marker struct { + Version string `yaml:"version"` + } + return yaml.Unmarshal(data, &marker) == nil && marker.Version == SpecURL } func ParsePointer(data []byte) (Pointer, error) { - if !IsPointer(data) { - return Pointer{}, fmt.Errorf("not a Confluence attachment pointer") - } - values := map[string]string{} - knownFields := map[string]bool{ - "version": true, - "source": true, - "page-id": true, - "attachment-id": true, - "attachment-version": true, - "filename": true, - "size": true, - "media-type": true, - "download-path": true, - } - lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") - for index, line := range lines { - if line == "" { - continue - } - key, value, ok := strings.Cut(line, " ") - if !ok || value == "" { - return Pointer{}, fmt.Errorf("invalid pointer line %d", index+1) - } - if !knownFields[key] { - return Pointer{}, fmt.Errorf("unknown pointer field %q", key) - } - if _, exists := values[key]; exists { - return Pointer{}, fmt.Errorf("duplicate pointer field %q", key) - } - values[key] = value - } - if values["version"] != SpecURL { - return Pointer{}, fmt.Errorf("unsupported attachment pointer version %q", values["version"]) - } - version, err := strconv.Atoi(values["attachment-version"]) - if err != nil || version <= 0 { - return Pointer{}, fmt.Errorf("invalid attachment-version %q", values["attachment-version"]) + var document pointerYAML + if err := yaml.Unmarshal(data, &document); err != nil { + return Pointer{}, fmt.Errorf("parse Confluence attachment pointer YAML: %w", err) } - size, err := strconv.ParseInt(values["size"], 10, 64) - if err != nil || size < 0 { - return Pointer{}, fmt.Errorf("invalid attachment size %q", values["size"]) + if document.Version != SpecURL { + return Pointer{}, fmt.Errorf("unsupported attachment pointer version %q", document.Version) } pointer := Pointer{ - SourceURL: values["source"], - PageID: values["page-id"], - AttachmentID: values["attachment-id"], - AttachmentVersion: version, - Filename: values["filename"], - Size: size, - MediaType: values["media-type"], - DownloadPath: values["download-path"], + SourceURL: document.SourceURL, + PageID: document.PageID, + AttachmentID: document.AttachmentID, + AttachmentVersion: document.AttachmentVersion, + Filename: document.Filename, + Size: document.Size, + MediaType: document.MediaType, + DownloadPath: document.DownloadPath, } if err := pointer.validate(); err != nil { return Pointer{}, err @@ -89,19 +68,21 @@ func ParsePointer(data []byte) (Pointer, error) { } func (p Pointer) Canonical() []byte { - var out strings.Builder - fmt.Fprintf(&out, "version %s\n", SpecURL) - fmt.Fprintf(&out, "source %s\n", strings.TrimRight(p.SourceURL, "/")) - fmt.Fprintf(&out, "page-id %s\n", p.PageID) - fmt.Fprintf(&out, "attachment-id %s\n", p.AttachmentID) - fmt.Fprintf(&out, "attachment-version %d\n", p.AttachmentVersion) - fmt.Fprintf(&out, "filename %s\n", p.Filename) - fmt.Fprintf(&out, "size %d\n", p.Size) - if p.MediaType != "" { - fmt.Fprintf(&out, "media-type %s\n", p.MediaType) + data, err := yaml.Marshal(pointerYAML{ + Version: SpecURL, + SourceURL: strings.TrimRight(p.SourceURL, "/"), + PageID: p.PageID, + AttachmentID: p.AttachmentID, + AttachmentVersion: p.AttachmentVersion, + Filename: p.Filename, + Size: p.Size, + MediaType: p.MediaType, + DownloadPath: p.DownloadPath, + }) + if err != nil { + panic(fmt.Sprintf("marshal Confluence attachment pointer: %v", err)) } - fmt.Fprintf(&out, "download-path %s\n", p.DownloadPath) - return []byte(out.String()) + return data } func (p Pointer) DownloadURL() (*url.URL, error) { diff --git a/internal/attachment/pointer_test.go b/internal/attachment/pointer_test.go index bd9d1ae..f09e9c1 100644 --- a/internal/attachment/pointer_test.go +++ b/internal/attachment/pointer_test.go @@ -21,6 +21,9 @@ func TestPointerCanonicalRoundTrip(t *testing.T) { DownloadPath: "/wiki/download/attachments/123/diagram%20one.png?api=v2", } data := pointer.Canonical() + if !bytes.HasPrefix(data, []byte("version: "+SpecURL+"\n")) { + t.Fatalf("canonical pointer is not YAML:\n%s", data) + } parsed, err := ParsePointer(data) if err != nil { t.Fatal(err) @@ -37,6 +40,24 @@ func TestPointerCanonicalRoundTrip(t *testing.T) { } } +func TestYAMLPointerAllowsReorderedAndQuotedFields(t *testing.T) { + data := []byte(`source: https://cf.example.test +version: "https://github.com/hkwi/git-remote-confluence/spec/attachment/v1" +attachment_id: "456" +page_id: "123" +filename: diagram.png +attachment_version: 7 +download_path: /download/attachments/123/diagram.png +size: 42 +`) + if !IsPointer(data) { + t.Fatal("reordered YAML was not recognized as a pointer") + } + if _, err := ParsePointer(data); err != nil { + t.Fatal(err) + } +} + func TestSmudgeDownloadsCachesAndCleanRestoresPointer(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -150,7 +171,7 @@ func TestSmudgeSizeMismatchWarnsAndUsesDownloadedContent(t *testing.T) { if output.String() != "wrong size" { t.Fatalf("smudge output = %q", output.String()) } - wantWarning := `git-confluence: warning: attachment "` + worktreePath + `": pointer size is 42 bytes, but downloaded content is 10 bytes; using downloaded content` + wantWarning := `level=WARN msg="attachment size differs from pointer; using downloaded content" app=git-confluence path=` + worktreePath + ` pointer_size=42 downloaded_size=10` if !strings.Contains(errorOutput.String(), wantWarning) { t.Fatalf("stderr = %q", errorOutput.String()) } @@ -198,18 +219,3 @@ func TestCleanDistinguishesSameContentAtDifferentPaths(t *testing.T) { t.Fatalf("download requests = %d, want 2", requests.Load()) } } - -func TestParsePointerRejectsUnknownField(t *testing.T) { - pointer := Pointer{ - SourceURL: "https://cf.example.test", - PageID: "1", - AttachmentID: "2", - AttachmentVersion: 3, - Filename: "file.bin", - DownloadPath: "/download/file.bin", - }.Canonical() - pointer = append(pointer, []byte("unexpected value\n")...) - if _, err := ParsePointer(pointer); err == nil || !strings.Contains(err.Error(), "unknown pointer field") { - t.Fatalf("ParsePointer error = %v", err) - } -} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..5352514 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,20 @@ +package logging + +import ( + "io" + "log/slog" +) + +// New returns a logger whose text output is stable logfmt. Command-line logs +// omit timestamps because the surrounding process already provides ordering. +func New(output io.Writer) *slog.Logger { + handler := slog.NewTextHandler(output, &slog.HandlerOptions{ + ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { + if attr.Key == slog.TimeKey { + return slog.Attr{} + } + return attr + }, + }) + return slog.New(handler) +} diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000..3233a21 --- /dev/null +++ b/internal/logging/logging_test.go @@ -0,0 +1,15 @@ +package logging + +import ( + "bytes" + "testing" +) + +func TestNewWritesLogfmt(t *testing.T) { + var output bytes.Buffer + New(&output).Warn("download failed", "path", "one two.png") + want := `level=WARN msg="download failed" path="one two.png"` + "\n" + if output.String() != want { + t.Fatalf("log output = %q, want %q", output.String(), want) + } +} diff --git a/main.go b/main.go index f241d76..8e835f8 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "strconv" "github.com/hkwi/git-confluence/internal/confluence" + "github.com/hkwi/git-confluence/internal/logging" ) const ( @@ -30,18 +31,21 @@ func main() { } switch os.Args[1] { + case "help": + fmt.Print(helpOutput()) + return case "version", "--version", "-version": fmt.Print(versionOutput()) return case "install": if err := installFilter(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) + logError(err) os.Exit(1) } return case "pull": if err := pullFiles(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) + logError(err) os.Exit(1) } return @@ -49,13 +53,13 @@ func main() { maxInput, err := maxInputBytes() if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) + logError(err) os.Exit(2) } maxDepth, err := maxRecursionDepth() if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) + logError(err) os.Exit(2) } @@ -101,14 +105,22 @@ func main() { } func usage() { - fmt.Fprintf(os.Stderr, "usage: %s clean|smudge|filter-clean |filter-smudge |install [--global|--local]|pull [path...]|version\n", appName) + fmt.Fprint(os.Stderr, helpOutput()) +} + +func helpOutput() string { + return fmt.Sprintf("usage: %s clean|smudge|filter-clean |filter-smudge |install [--global|--local]|pull [path...]|version|help\n", appName) } func fail(err error) { - fmt.Fprintf(os.Stderr, "%s: %v\n", appName, err) + logError(err) os.Exit(1) } +func logError(err error) { + logging.New(os.Stderr).Error(err.Error(), "app", appName) +} + func versionOutput() string { return fmt.Sprintf("%s %s\ncommit: %s\nbuilt: %s\n", appName, releaseVersion(), commit, date) } diff --git a/main_test.go b/main_test.go index 2e9c099..288eb2a 100644 --- a/main_test.go +++ b/main_test.go @@ -68,6 +68,14 @@ func TestVersionOutput(t *testing.T) { } } +func TestHelpOutput(t *testing.T) { + got := helpOutput() + want := "usage: git-confluence clean|smudge|filter-clean |filter-smudge |install [--global|--local]|pull [path...]|version|help\n" + if got != want { + t.Fatalf("helpOutput() = %q, want %q", got, want) + } +} + func TestUnifiedFilterMaterializesAttachmentAndCleansBackToPointer(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/download/file.bin" || r.URL.Query().Get("version") != "3" { From 9c169cafbf51b7c0e8260160bac007b6eb7f4c0f Mon Sep 17 00:00:00 2001 From: Hiroaki KAWAI Date: Fri, 21 Aug 2026 12:03:25 +0900 Subject: [PATCH 5/8] Report attachment checkout progress --- README.md | 9 +++++++++ internal/attachment/filter.go | 28 ++++++++++++++++++++-------- internal/attachment/pointer_test.go | 14 ++++++++++++-- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 269bd4b..2a35005 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,15 @@ Keep attachment pointers while still converting page XML to Markdown: GIT_CONFLUENCE_SKIP_SMUDGE=1 git checkout ``` +Attachment checkout progress is written to stderr as logfmt. Each record names +the working-tree path, attachment ID, and version, and reports cache hits and +download progress without exposing the Confluence PAT: + +```text +level=INFO msg="downloading attachment" app=git-confluence path=123/attachments/diagram.png attachment_id=456 attachment_version=2 +level=INFO msg="downloaded attachment" app=git-confluence path=123/attachments/diagram.png attachment_id=456 attachment_version=2 bytes=42000 +``` + Materialize all attachments, or selected paths, after checkout: ```sh diff --git a/internal/attachment/filter.go b/internal/attachment/filter.go index 6316114..310ae7b 100644 --- a/internal/attachment/filter.go +++ b/internal/attachment/filter.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "io" + "log/slog" "net/http" "os" "os/exec" @@ -29,16 +30,24 @@ func Smudge(pointerData []byte, worktreePath string, output, errorOutput io.Writ if err != nil { return err } + logger := logging.New(errorOutput).With( + "app", "git-confluence", + "path", worktreePath, + "attachment_id", pointer.AttachmentID, + "attachment_version", pointer.AttachmentVersion, + ) canonical := pointer.Canonical() if os.Getenv(skipSmudgeEnv) != "" { _, err = output.Write(canonical) return err } + logger.Info("materializing attachment") cache, err := openCache() if err != nil { return err } if path, ok := cache.lookupPointer(canonical); ok { + logger.Info("using cached attachment") if err := cache.remember(filepath.Base(path), worktreePath, canonical); err != nil { return err } @@ -46,14 +55,13 @@ func Smudge(pointerData []byte, worktreePath string, output, errorOutput io.Writ } pat := resolvePAT() if pat == "" { - logging.New(errorOutput).Warn("Confluence PAT is not configured; leaving attachment pointer in working tree", "app", "git-confluence") + logger.Warn("Confluence PAT is not configured; leaving attachment pointer in working tree") _, err = output.Write(canonical) return err } - path, err := download(cache, pointer, canonical, worktreePath, pat, errorOutput) + path, err := download(cache, pointer, canonical, worktreePath, pat, logger) if err != nil { - logging.New(errorOutput).Warn("attachment download failed; leaving attachment pointer in working tree", - "app", "git-confluence", "path", worktreePath, "error", err) + logger.Warn("attachment download failed; leaving attachment pointer in working tree", "error", err) _, writeErr := output.Write(canonical) return writeErr } @@ -114,8 +122,7 @@ func Clean(input io.Reader, worktreePath string, output io.Writer) error { return err } -func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat string, warningOutput io.Writer) (string, error) { - logger := logging.New(warningOutput) +func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat string, logger *slog.Logger) (string, error) { downloadURL, err := pointer.DownloadURL() if err != nil { return "", err @@ -133,6 +140,7 @@ func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat } return nil } + logger.Info("downloading attachment") response, err := client.Do(request) if err != nil { return "", fmt.Errorf("download attachment %s version %d: %w", pointer.AttachmentID, pointer.AttachmentVersion, err) @@ -141,6 +149,7 @@ func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat if response.StatusCode < 200 || response.StatusCode >= 300 { return "", fmt.Errorf("download attachment %s version %d: HTTP %d", pointer.AttachmentID, pointer.AttachmentVersion, response.StatusCode) } + logger.Info("receiving attachment", "status", response.StatusCode) maxBytes, err := maxAttachmentBytes() if err != nil { return "", err @@ -165,11 +174,14 @@ func download(cache cache, pointer Pointer, canonical []byte, worktreePath, pat } if pointer.Size > 0 && written != pointer.Size { logger.Warn("attachment size differs from pointer; using downloaded content", - "app", "git-confluence", "path", worktreePath, "pointer_size", pointer.Size, "downloaded_size", written) } oid := hex.EncodeToString(hash.Sum(nil)) - return cache.store(tempPath, oid, worktreePath, canonical) + path, err := cache.store(tempPath, oid, worktreePath, canonical) + if err == nil { + logger.Info("downloaded attachment", "bytes", written) + } + return path, err } func copyFile(path string, output io.Writer) error { diff --git a/internal/attachment/pointer_test.go b/internal/attachment/pointer_test.go index f09e9c1..2cd1746 100644 --- a/internal/attachment/pointer_test.go +++ b/internal/attachment/pointer_test.go @@ -87,12 +87,22 @@ func TestSmudgeDownloadsCachesAndCleanRestoresPointer(t *testing.T) { for attempt := 0; attempt < 2; attempt++ { var output bytes.Buffer - if err := Smudge(pointer, "123/attachments/file.bin", &output, &bytes.Buffer{}); err != nil { + var logs bytes.Buffer + if err := Smudge(pointer, "123/attachments/file.bin", &output, &logs); err != nil { t.Fatal(err) } if output.String() != "attachment data" { t.Fatalf("smudge output = %q", output.String()) } + if !strings.Contains(logs.String(), `level=INFO msg="materializing attachment" app=git-confluence path=123/attachments/file.bin attachment_id=456 attachment_version=2`) { + t.Fatalf("materialization log missing:\n%s", logs.String()) + } + if attempt == 0 && !strings.Contains(logs.String(), `level=INFO msg="downloaded attachment" app=git-confluence path=123/attachments/file.bin attachment_id=456 attachment_version=2 bytes=15`) { + t.Fatalf("download log missing:\n%s", logs.String()) + } + if attempt == 1 && !strings.Contains(logs.String(), `level=INFO msg="using cached attachment" app=git-confluence path=123/attachments/file.bin attachment_id=456 attachment_version=2`) { + t.Fatalf("cache log missing:\n%s", logs.String()) + } } t.Setenv("CONFLUENCE_PAT", "") t.Setenv("GIT_REMOTE_CONFLUENCE_PAT", "") @@ -171,7 +181,7 @@ func TestSmudgeSizeMismatchWarnsAndUsesDownloadedContent(t *testing.T) { if output.String() != "wrong size" { t.Fatalf("smudge output = %q", output.String()) } - wantWarning := `level=WARN msg="attachment size differs from pointer; using downloaded content" app=git-confluence path=` + worktreePath + ` pointer_size=42 downloaded_size=10` + wantWarning := `level=WARN msg="attachment size differs from pointer; using downloaded content" app=git-confluence path=` + worktreePath + ` attachment_id=2 attachment_version=3 pointer_size=42 downloaded_size=10` if !strings.Contains(errorOutput.String(), wantWarning) { t.Fatalf("stderr = %q", errorOutput.String()) } From 8091ae1a53b5124aa214c71ebf36722228447f9e Mon Sep 17 00:00:00 2001 From: Hiroaki KAWAI Date: Fri, 21 Aug 2026 12:12:17 +0900 Subject: [PATCH 6/8] Stop list conversion from looping --- README.md | 4 ++++ filter.go | 13 ++++++++++++- filter_test.go | 23 +++++++++++++++++++++++ internal/confluence/converter.go | 2 +- internal/confluence/list_test.go | 22 ++++++++++++++++++++++ main.go | 2 +- 6 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 filter_test.go create mode 100644 internal/confluence/list_test.go diff --git a/README.md b/README.md index 2a35005..b8a38ff 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,10 @@ level=INFO msg="downloading attachment" app=git-confluence path=123/attachments/ level=INFO msg="downloaded attachment" app=git-confluence path=123/attachments/diagram.png attachment_id=456 attachment_version=2 bytes=42000 ``` +Page clean and smudge conversion also reports start and completion records, +including the path and input or output byte count. This makes a slow page +conversion distinguishable from an attachment download. + Materialize all attachments, or selected paths, after checkout: ```sh diff --git a/filter.go b/filter.go index 949351e..8bc1c7e 100644 --- a/filter.go +++ b/filter.go @@ -10,9 +10,10 @@ import ( "github.com/hkwi/git-confluence/internal/attachment" "github.com/hkwi/git-confluence/internal/confluence" + "github.com/hkwi/git-confluence/internal/logging" ) -func filterClean(path string, input io.Reader, output io.Writer, maxInput int64, maxDepth int) error { +func filterClean(path string, input io.Reader, output, errorOutput io.Writer, maxInput int64, maxDepth int) error { if isAttachmentPath(path) { return attachment.Clean(input, path, output) } @@ -20,11 +21,16 @@ func filterClean(path string, input io.Reader, output io.Writer, maxInput int64, if err != nil { return err } + logger := logging.New(errorOutput).With("app", "git-confluence", "path", path) + logger.Info("converting Markdown to Confluence storage", "bytes", len(data)) storage, err := confluence.MarkdownToStorageWithMaxDepth(string(data), maxDepth) if err != nil { return err } _, err = io.WriteString(output, storage) + if err == nil { + logger.Info("converted Markdown to Confluence storage", "bytes", len(storage)) + } return err } @@ -36,11 +42,16 @@ func filterSmudge(path string, input io.Reader, output, errorOutput io.Writer, m if isAttachmentPath(path) || attachment.IsPointer(data) { return attachment.Smudge(data, path, output, errorOutput) } + logger := logging.New(errorOutput).With("app", "git-confluence", "path", path) + logger.Info("converting Confluence storage to Markdown", "bytes", len(data)) markdown, err := confluence.StorageToMarkdownWithMaxDepth(string(data), maxDepth) if err != nil { return err } _, err = io.WriteString(output, markdown) + if err == nil { + logger.Info("converted Confluence storage to Markdown", "bytes", len(markdown)) + } return err } diff --git a/filter_test.go b/filter_test.go new file mode 100644 index 0000000..7d8f6d6 --- /dev/null +++ b/filter_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestFilterCleanReportsPageConversion(t *testing.T) { + var output bytes.Buffer + var logs bytes.Buffer + if err := filterClean("1.md", strings.NewReader("吾輩は猫である。\n"), &output, &logs, defaultMaxInputBytes, 32); err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `level=INFO msg="converting Markdown to Confluence storage" app=git-confluence path=1.md bytes=`, + `level=INFO msg="converted Markdown to Confluence storage" app=git-confluence path=1.md bytes=`, + } { + if !strings.Contains(logs.String(), want) { + t.Fatalf("log missing %q:\n%s", want, logs.String()) + } + } +} diff --git a/internal/confluence/converter.go b/internal/confluence/converter.go index 9b12b55..83cd96b 100644 --- a/internal/confluence/converter.go +++ b/internal/confluence/converter.go @@ -515,7 +515,7 @@ func collectListAtIndent(lines []string, start, indent int, ordered bool, depth, i++ continue } - if lineIndent(lines[i]) <= indent { + if lineIndent(lines[i]) < indent+2 { break } blockLines, nextIndex := collectListContinuation(lines, i, indent+2) diff --git a/internal/confluence/list_test.go b/internal/confluence/list_test.go new file mode 100644 index 0000000..384913b --- /dev/null +++ b/internal/confluence/list_test.go @@ -0,0 +1,22 @@ +package confluence + +import ( + "strings" + "testing" +) + +func TestMarkdownListStopsBeforeInsufficientlyIndentedFollowingLine(t *testing.T) { + markdown := "- list item\n continuation with one leading space\n" + storage, err := MarkdownToStorageWithMaxDepth(markdown, DefaultMarkdownRecursionDepth) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "
    \n
  • list item
  • \n
", + "

continuation with one leading space

", + } { + if !strings.Contains(storage, want) { + t.Fatalf("storage missing %q:\n%s", want, storage) + } + } +} diff --git a/main.go b/main.go index 8e835f8..0ea05df 100644 --- a/main.go +++ b/main.go @@ -88,7 +88,7 @@ func main() { if len(os.Args) != 3 { fail(fmt.Errorf("filter-clean requires a pathname")) } - if err := filterClean(os.Args[2], os.Stdin, os.Stdout, maxInput, maxDepth); err != nil { + if err := filterClean(os.Args[2], os.Stdin, os.Stdout, os.Stderr, maxInput, maxDepth); err != nil { fail(err) } case "filter-smudge": From 293b87196f0324e530244dd96e5f59530c5b64ac Mon Sep 17 00:00:00 2001 From: Hiroaki KAWAI Date: Fri, 21 Aug 2026 12:25:38 +0900 Subject: [PATCH 7/8] Clarify filter log purpose --- README.md | 10 ++++++---- filter.go | 20 ++++++++++++++------ filter_test.go | 20 ++++++++++++++++++-- internal/attachment/filter.go | 3 +++ internal/attachment/pointer_test.go | 4 ++-- 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b8a38ff..61a14e7 100644 --- a/README.md +++ b/README.md @@ -167,13 +167,15 @@ the working-tree path, attachment ID, and version, and reports cache hits and download progress without exposing the Confluence PAT: ```text -level=INFO msg="downloading attachment" app=git-confluence path=123/attachments/diagram.png attachment_id=456 attachment_version=2 -level=INFO msg="downloaded attachment" app=git-confluence path=123/attachments/diagram.png attachment_id=456 attachment_version=2 bytes=42000 +level=INFO msg="downloading attachment" app=git-confluence path=123/attachments/diagram.png attachment_id=456 attachment_version=2 filter=smudge direction=attachment_pointer_to_bytes purpose=materialize_worktree +level=INFO msg="downloaded attachment" app=git-confluence path=123/attachments/diagram.png attachment_id=456 attachment_version=2 filter=smudge direction=attachment_pointer_to_bytes purpose=materialize_worktree bytes=42000 ``` Page clean and smudge conversion also reports start and completion records, -including the path and input or output byte count. This makes a slow page -conversion distinguishable from an attachment download. +including the path, filter, direction, purpose, and input or output byte count. +The clean filter normalizes worktree content for Git comparison or storage; +the smudge filter materializes Git content in the worktree. This makes a slow +page conversion distinguishable from an attachment download. Materialize all attachments, or selected paths, after checkout: diff --git a/filter.go b/filter.go index 8bc1c7e..4c2cbcc 100644 --- a/filter.go +++ b/filter.go @@ -21,15 +21,19 @@ func filterClean(path string, input io.Reader, output, errorOutput io.Writer, ma if err != nil { return err } - logger := logging.New(errorOutput).With("app", "git-confluence", "path", path) - logger.Info("converting Markdown to Confluence storage", "bytes", len(data)) + logger := logging.New(errorOutput).With( + "app", "git-confluence", "path", path, + "filter", "clean", "direction", "markdown_to_confluence_storage", + "purpose", "normalize_for_git", + ) + logger.Info("clean filter started", "bytes", len(data)) storage, err := confluence.MarkdownToStorageWithMaxDepth(string(data), maxDepth) if err != nil { return err } _, err = io.WriteString(output, storage) if err == nil { - logger.Info("converted Markdown to Confluence storage", "bytes", len(storage)) + logger.Info("clean filter completed", "bytes", len(storage)) } return err } @@ -42,15 +46,19 @@ func filterSmudge(path string, input io.Reader, output, errorOutput io.Writer, m if isAttachmentPath(path) || attachment.IsPointer(data) { return attachment.Smudge(data, path, output, errorOutput) } - logger := logging.New(errorOutput).With("app", "git-confluence", "path", path) - logger.Info("converting Confluence storage to Markdown", "bytes", len(data)) + logger := logging.New(errorOutput).With( + "app", "git-confluence", "path", path, + "filter", "smudge", "direction", "confluence_storage_to_markdown", + "purpose", "materialize_worktree", + ) + logger.Info("smudge filter started", "bytes", len(data)) markdown, err := confluence.StorageToMarkdownWithMaxDepth(string(data), maxDepth) if err != nil { return err } _, err = io.WriteString(output, markdown) if err == nil { - logger.Info("converted Confluence storage to Markdown", "bytes", len(markdown)) + logger.Info("smudge filter completed", "bytes", len(markdown)) } return err } diff --git a/filter_test.go b/filter_test.go index 7d8f6d6..bc96bbf 100644 --- a/filter_test.go +++ b/filter_test.go @@ -13,8 +13,24 @@ func TestFilterCleanReportsPageConversion(t *testing.T) { t.Fatal(err) } for _, want := range []string{ - `level=INFO msg="converting Markdown to Confluence storage" app=git-confluence path=1.md bytes=`, - `level=INFO msg="converted Markdown to Confluence storage" app=git-confluence path=1.md bytes=`, + `level=INFO msg="clean filter started" app=git-confluence path=1.md filter=clean direction=markdown_to_confluence_storage purpose=normalize_for_git bytes=`, + `level=INFO msg="clean filter completed" app=git-confluence path=1.md filter=clean direction=markdown_to_confluence_storage purpose=normalize_for_git bytes=`, + } { + if !strings.Contains(logs.String(), want) { + t.Fatalf("log missing %q:\n%s", want, logs.String()) + } + } +} + +func TestFilterSmudgeReportsPageConversion(t *testing.T) { + var output bytes.Buffer + var logs bytes.Buffer + if err := filterSmudge("1.md", strings.NewReader("

吾輩は猫である。

\n"), &output, &logs, defaultMaxInputBytes, 32); err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `level=INFO msg="smudge filter started" app=git-confluence path=1.md filter=smudge direction=confluence_storage_to_markdown purpose=materialize_worktree bytes=`, + `level=INFO msg="smudge filter completed" app=git-confluence path=1.md filter=smudge direction=confluence_storage_to_markdown purpose=materialize_worktree bytes=`, } { if !strings.Contains(logs.String(), want) { t.Fatalf("log missing %q:\n%s", want, logs.String()) diff --git a/internal/attachment/filter.go b/internal/attachment/filter.go index 310ae7b..dd09024 100644 --- a/internal/attachment/filter.go +++ b/internal/attachment/filter.go @@ -35,6 +35,9 @@ func Smudge(pointerData []byte, worktreePath string, output, errorOutput io.Writ "path", worktreePath, "attachment_id", pointer.AttachmentID, "attachment_version", pointer.AttachmentVersion, + "filter", "smudge", + "direction", "attachment_pointer_to_bytes", + "purpose", "materialize_worktree", ) canonical := pointer.Canonical() if os.Getenv(skipSmudgeEnv) != "" { diff --git a/internal/attachment/pointer_test.go b/internal/attachment/pointer_test.go index 2cd1746..218d4d6 100644 --- a/internal/attachment/pointer_test.go +++ b/internal/attachment/pointer_test.go @@ -97,7 +97,7 @@ func TestSmudgeDownloadsCachesAndCleanRestoresPointer(t *testing.T) { if !strings.Contains(logs.String(), `level=INFO msg="materializing attachment" app=git-confluence path=123/attachments/file.bin attachment_id=456 attachment_version=2`) { t.Fatalf("materialization log missing:\n%s", logs.String()) } - if attempt == 0 && !strings.Contains(logs.String(), `level=INFO msg="downloaded attachment" app=git-confluence path=123/attachments/file.bin attachment_id=456 attachment_version=2 bytes=15`) { + if attempt == 0 && !strings.Contains(logs.String(), `level=INFO msg="downloaded attachment" app=git-confluence path=123/attachments/file.bin attachment_id=456 attachment_version=2 filter=smudge direction=attachment_pointer_to_bytes purpose=materialize_worktree bytes=15`) { t.Fatalf("download log missing:\n%s", logs.String()) } if attempt == 1 && !strings.Contains(logs.String(), `level=INFO msg="using cached attachment" app=git-confluence path=123/attachments/file.bin attachment_id=456 attachment_version=2`) { @@ -181,7 +181,7 @@ func TestSmudgeSizeMismatchWarnsAndUsesDownloadedContent(t *testing.T) { if output.String() != "wrong size" { t.Fatalf("smudge output = %q", output.String()) } - wantWarning := `level=WARN msg="attachment size differs from pointer; using downloaded content" app=git-confluence path=` + worktreePath + ` attachment_id=2 attachment_version=3 pointer_size=42 downloaded_size=10` + wantWarning := `level=WARN msg="attachment size differs from pointer; using downloaded content" app=git-confluence path=` + worktreePath + ` attachment_id=2 attachment_version=3 filter=smudge direction=attachment_pointer_to_bytes purpose=materialize_worktree pointer_size=42 downloaded_size=10` if !strings.Contains(errorOutput.String(), wantWarning) { t.Fatalf("stderr = %q", errorOutput.String()) } From c2f0a85bc71d89bac457f4f2ac8e636d851f4a46 Mon Sep 17 00:00:00 2001 From: Hiroaki KAWAI Date: Fri, 21 Aug 2026 13:05:52 +0900 Subject: [PATCH 8/8] Preserve original page storage through filters --- README.md | 9 ++++ filter.go | 26 ++++++++++ filter_test.go | 27 ++++++++++ internal/pagecache/cache.go | 85 ++++++++++++++++++++++++++++++++ internal/pagecache/cache_test.go | 34 +++++++++++++ main_test.go | 46 +++++++++++++++-- 6 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 internal/pagecache/cache.go create mode 100644 internal/pagecache/cache_test.go diff --git a/README.md b/README.md index 61a14e7..4d2ed6a 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,15 @@ The clean filter normalizes worktree content for Git comparison or storage; the smudge filter materializes Git content in the worktree. This makes a slow page conversion distinguishable from an attachment download. +Page smudge stores the exact source storage XML below +`$GIT_DIR/confluence/pages`. When the resulting Markdown is unchanged, clean +restores those exact bytes instead of regenerating XML. Edited Markdown is +converted normally. This keeps an untouched checkout clean even when its source +contains Confluence attributes that Markdown cannot represent. If the filter is +installed after a normal checkout, an existing working file identical to its +index blob is also passed through unchanged until it is explicitly checked out +through smudge. + Materialize all attachments, or selected paths, after checkout: ```sh diff --git a/filter.go b/filter.go index 4c2cbcc..1036c83 100644 --- a/filter.go +++ b/filter.go @@ -11,6 +11,7 @@ import ( "github.com/hkwi/git-confluence/internal/attachment" "github.com/hkwi/git-confluence/internal/confluence" "github.com/hkwi/git-confluence/internal/logging" + "github.com/hkwi/git-confluence/internal/pagecache" ) func filterClean(path string, input io.Reader, output, errorOutput io.Writer, maxInput int64, maxDepth int) error { @@ -27,6 +28,24 @@ func filterClean(path string, input io.Reader, output, errorOutput io.Writer, ma "purpose", "normalize_for_git", ) logger.Info("clean filter started", "bytes", len(data)) + cache, err := pagecache.Open() + if err != nil { + return err + } + originalStorage, ok, err := cache.Original(data, path) + if err != nil { + return fmt.Errorf("read cached original Confluence storage: %w", err) + } + if ok { + logger.Info("clean filter restored original storage", "bytes", len(originalStorage), "source", "page_cache") + _, err = output.Write(originalStorage) + return err + } + if pagecache.MatchesIndex(path, data) { + logger.Info("clean filter preserved indexed storage", "bytes", len(data), "source", "git_index") + _, err = output.Write(data) + return err + } storage, err := confluence.MarkdownToStorageWithMaxDepth(string(data), maxDepth) if err != nil { return err @@ -56,6 +75,13 @@ func filterSmudge(path string, input io.Reader, output, errorOutput io.Writer, m if err != nil { return err } + cache, err := pagecache.Open() + if err != nil { + return err + } + if err := cache.Remember([]byte(markdown), path, data); err != nil { + return fmt.Errorf("cache original Confluence storage: %w", err) + } _, err = io.WriteString(output, markdown) if err == nil { logger.Info("smudge filter completed", "bytes", len(markdown)) diff --git a/filter_test.go b/filter_test.go index bc96bbf..a9f6583 100644 --- a/filter_test.go +++ b/filter_test.go @@ -7,6 +7,7 @@ import ( ) func TestFilterCleanReportsPageConversion(t *testing.T) { + t.Setenv("GIT_CONFLUENCE_CACHE_DIR", t.TempDir()) var output bytes.Buffer var logs bytes.Buffer if err := filterClean("1.md", strings.NewReader("吾輩は猫である。\n"), &output, &logs, defaultMaxInputBytes, 32); err != nil { @@ -23,6 +24,7 @@ func TestFilterCleanReportsPageConversion(t *testing.T) { } func TestFilterSmudgeReportsPageConversion(t *testing.T) { + t.Setenv("GIT_CONFLUENCE_CACHE_DIR", t.TempDir()) var output bytes.Buffer var logs bytes.Buffer if err := filterSmudge("1.md", strings.NewReader("

吾輩は猫である。

\n"), &output, &logs, defaultMaxInputBytes, 32); err != nil { @@ -37,3 +39,28 @@ func TestFilterSmudgeReportsPageConversion(t *testing.T) { } } } + +func TestPageFilterRestoresExactOriginalStorageWhenMarkdownIsUnchanged(t *testing.T) { + t.Setenv("GIT_CONFLUENCE_CACHE_DIR", t.TempDir()) + storage := []byte(`
  • one
`) + var markdown bytes.Buffer + if err := filterSmudge("1.md", bytes.NewReader(storage), &markdown, &bytes.Buffer{}, defaultMaxInputBytes, 32); err != nil { + t.Fatal(err) + } + var cleaned bytes.Buffer + if err := filterClean("1.md", bytes.NewReader(markdown.Bytes()), &cleaned, &bytes.Buffer{}, defaultMaxInputBytes, 32); err != nil { + t.Fatal(err) + } + if !bytes.Equal(cleaned.Bytes(), storage) { + t.Fatalf("clean(smudge(storage)):\n%s\nwant:\n%s", cleaned.Bytes(), storage) + } + + edited := append(bytes.Clone(markdown.Bytes()), []byte("\nedited\n")...) + cleaned.Reset() + if err := filterClean("1.md", bytes.NewReader(edited), &cleaned, &bytes.Buffer{}, defaultMaxInputBytes, 32); err != nil { + t.Fatal(err) + } + if bytes.Equal(cleaned.Bytes(), storage) || !bytes.Contains(cleaned.Bytes(), []byte("edited")) { + t.Fatalf("edited Markdown was restored from cache:\n%s", cleaned.Bytes()) + } +} diff --git a/internal/pagecache/cache.go b/internal/pagecache/cache.go new file mode 100644 index 0000000..5b0b23d --- /dev/null +++ b/internal/pagecache/cache.go @@ -0,0 +1,85 @@ +package pagecache + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const cacheDirEnv = "GIT_CONFLUENCE_CACHE_DIR" + +type Cache struct { + root string +} + +func Open() (Cache, error) { + root := os.Getenv(cacheDirEnv) + if root == "" { + output, err := exec.Command("git", "rev-parse", "--git-path", "confluence").Output() + if err != nil { + return Cache{}, fmt.Errorf("locate Git Confluence cache: %w", err) + } + root = strings.TrimSpace(string(output)) + } + if root == "" { + return Cache{}, fmt.Errorf("Git Confluence cache path is empty") + } + return Cache{root: filepath.Join(root, "pages")}, nil +} + +func (c Cache) Remember(markdown []byte, worktreePath string, storage []byte) error { + path := c.mappingPath(markdown, worktreePath) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return writeAtomic(path, storage) +} + +func (c Cache) Original(markdown []byte, worktreePath string) ([]byte, bool, error) { + data, err := os.ReadFile(c.mappingPath(markdown, worktreePath)) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return data, true, nil +} + +func MatchesIndex(worktreePath string, data []byte) bool { + object := ":" + filepath.ToSlash(worktreePath) + indexed, err := exec.Command("git", "cat-file", "blob", object).Output() + return err == nil && bytes.Equal(indexed, data) +} + +func (c Cache) mappingPath(markdown []byte, worktreePath string) string { + contentHash := sha256.Sum256(markdown) + pathHash := sha256.Sum256([]byte(filepath.ToSlash(worktreePath))) + return filepath.Join(c.root, "by-content", hex.EncodeToString(contentHash[:]), hex.EncodeToString(pathHash[:])) +} + +func writeAtomic(path string, data []byte) error { + temp, err := os.CreateTemp(filepath.Dir(path), "mapping-*") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(tempPath, path) +} diff --git a/internal/pagecache/cache_test.go b/internal/pagecache/cache_test.go new file mode 100644 index 0000000..3167fa6 --- /dev/null +++ b/internal/pagecache/cache_test.go @@ -0,0 +1,34 @@ +package pagecache + +import ( + "bytes" + "testing" +) + +func TestCacheRestoresOriginalByContentAndPath(t *testing.T) { + cache := Cache{root: t.TempDir()} + markdown := []byte("same Markdown\n") + originals := map[string][]byte{ + "1.md": []byte(`
  • one
`), + "2.md": []byte(`
  • two
`), + } + for path, storage := range originals { + if err := cache.Remember(markdown, path, storage); err != nil { + t.Fatal(err) + } + } + for path, want := range originals { + got, ok, err := cache.Original(markdown, path) + if err != nil { + t.Fatal(err) + } + if !ok || !bytes.Equal(got, want) { + t.Fatalf("Original(%q) = %q, %v; want %q", path, got, ok, want) + } + } + if _, ok, err := cache.Original([]byte("edited Markdown\n"), "1.md"); err != nil { + t.Fatal(err) + } else if ok { + t.Fatal("edited Markdown unexpectedly matched the cache") + } +} diff --git a/main_test.go b/main_test.go index 288eb2a..c51f60a 100644 --- a/main_test.go +++ b/main_test.go @@ -113,7 +113,12 @@ func TestUnifiedFilterMaterializesAttachmentAndCleansBackToPointer(t *testing.T) if err := os.WriteFile(attachmentPath, pointer, 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(repo, ".gitattributes"), []byte("**/attachments/** filter=confluence -text\n"), 0o644); err != nil { + pageStorage := []byte(`
  • one
`) + pagePath := filepath.Join(repo, "1.md") + if err := os.WriteFile(pagePath, pageStorage, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".gitattributes"), []byte("*.md filter=confluence diff=markdown\n**/attachments/** filter=confluence -text\n"), 0o644); err != nil { t.Fatal(err) } @@ -127,14 +132,33 @@ func TestUnifiedFilterMaterializesAttachmentAndCleansBackToPointer(t *testing.T) runGit(t, repo, env, "init") runGit(t, repo, env, "config", "user.name", "Test") runGit(t, repo, env, "config", "user.email", "test@example.invalid") + runGit(t, repo, env, "add", ".") + runGit(t, repo, env, "commit", "-m", "pointer and page storage") + install := exec.Command(filterPath, "install", "--local") install.Dir = repo install.Env = env if output, err := install.CombinedOutput(); err != nil { t.Fatalf("install filter: %v\n%s", err, output) } - runGit(t, repo, env, "add", ".") - runGit(t, repo, env, "commit", "-m", "pointer") + if status := runGitStdout(t, repo, env, "status", "--short"); status != "" { + t.Fatalf("install after checkout marked indexed storage modified: %q", status) + } + if err := os.Remove(pagePath); err != nil { + t.Fatal(err) + } + runGit(t, repo, env, "checkout", "HEAD", "--", "1.md") + markdown, err := os.ReadFile(pagePath) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(markdown, pageStorage) || !bytes.Contains(markdown, []byte("- one")) { + t.Fatalf("smudged page = %q", markdown) + } + if status := runGitStdout(t, repo, env, "status", "--short"); status != "" { + t.Fatalf("worktree status after page smudge = %q", status) + } + if err := os.Remove(attachmentPath); err != nil { t.Fatal(err) } @@ -146,7 +170,7 @@ func TestUnifiedFilterMaterializesAttachmentAndCleansBackToPointer(t *testing.T) if string(materialized) != "attachment bytes" { t.Fatalf("materialized attachment = %q", materialized) } - if status := runGit(t, repo, env, "status", "--short"); status != "" { + if status := runGitStdout(t, repo, env, "status", "--short"); status != "" { attr := runGit(t, repo, env, "check-attr", "filter", "--", "1/attachments/file.bin") cleanConfig := runGit(t, repo, env, "config", "--get", "filter.confluence.clean") indexOID := runGit(t, repo, env, "rev-parse", ":1/attachments/file.bin") @@ -201,3 +225,17 @@ func runGit(t *testing.T, dir string, env []string, args ...string) string { } return strings.TrimSpace(string(output)) } + +func runGitStdout(t *testing.T, dir string, env []string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("git %s: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout.String(), stderr.String()) + } + return strings.TrimSpace(stdout.String()) +}