diff --git a/README.md b/README.md index 9a9bdb1..4d2ed6a 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 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 +upload support is implemented. ## Storage And Working Formats @@ -39,6 +42,16 @@ 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 | + +Attachment pointers use the `attachment/v1` YAML schema. + This split preserves storage XML for Confluence synchronization while allowing people to edit Markdown. @@ -94,26 +107,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 +144,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 +156,46 @@ 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 +``` + +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 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, 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. + +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 +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 +208,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..1036c83 --- /dev/null +++ b/filter.go @@ -0,0 +1,184 @@ +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" + "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 { + if isAttachmentPath(path) { + return attachment.Clean(input, path, output) + } + data, err := readLimitedInput(input, maxInput) + if err != nil { + return err + } + 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)) + 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 + } + _, err = io.WriteString(output, storage) + if err == nil { + logger.Info("clean filter completed", "bytes", len(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) + } + 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 + } + 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)) + } + 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/filter_test.go b/filter_test.go new file mode 100644 index 0000000..a9f6583 --- /dev/null +++ b/filter_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +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 { + t.Fatal(err) + } + for _, want := range []string{ + `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) { + 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 { + 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()) + } + } +} + +func TestPageFilterRestoresExactOriginalStorageWhenMarkdownIsUnchanged(t *testing.T) { + t.Setenv("GIT_CONFLUENCE_CACHE_DIR", t.TempDir()) + storage := []byte(``) + 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/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/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..dd09024 --- /dev/null +++ b/internal/attachment/filter.go @@ -0,0 +1,225 @@ +package attachment + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/hkwi/git-confluence/internal/logging" +) + +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 + } + logger := logging.New(errorOutput).With( + "app", "git-confluence", + "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) != "" { + _, 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 + } + return copyFile(path, output) + } + pat := resolvePAT() + if pat == "" { + 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, logger) + if err != nil { + logger.Warn("attachment download failed; leaving attachment pointer in working tree", "error", 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, logger *slog.Logger) (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 + } + 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) + } + 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) + } + logger.Info("receiving attachment", "status", 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 { + logger.Warn("attachment size differs from pointer; using downloaded content", + "pointer_size", pointer.Size, "downloaded_size", written) + } + oid := hex.EncodeToString(hash.Sum(nil)) + 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 { + 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..4eb5988 --- /dev/null +++ b/internal/attachment/pointer.go @@ -0,0 +1,125 @@ +package attachment + +import ( + "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" + +type Pointer struct { + SourceURL string + PageID string + AttachmentID string + AttachmentVersion int + Filename string + Size int64 + MediaType string + 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 { + var marker struct { + Version string `yaml:"version"` + } + return yaml.Unmarshal(data, &marker) == nil && marker.Version == SpecURL +} + +func ParsePointer(data []byte) (Pointer, error) { + var document pointerYAML + if err := yaml.Unmarshal(data, &document); err != nil { + return Pointer{}, fmt.Errorf("parse Confluence attachment pointer YAML: %w", err) + } + if document.Version != SpecURL { + return Pointer{}, fmt.Errorf("unsupported attachment pointer version %q", document.Version) + } + pointer := Pointer{ + 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 + } + return pointer, nil +} + +func (p Pointer) Canonical() []byte { + 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)) + } + return data +} + +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..218d4d6 --- /dev/null +++ b/internal/attachment/pointer_test.go @@ -0,0 +1,231 @@ +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() + 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) + } + 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 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) { + 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 + 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 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`) { + t.Fatalf("cache log missing:\n%s", logs.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 TestSmudgeSizeMismatchWarnsAndUsesDownloadedContent(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 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 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()) + } +} + +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()) + } +} 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{ + "", + "

continuation with one leading space

", + } { + if !strings.Contains(storage, want) { + t.Fatalf("storage missing %q:\n%s", want, storage) + } + } +} 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/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(``), + "2.md": []byte(``), + } + 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.go b/main.go index 2e216b6..0ea05df 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 ( @@ -24,50 +25,79 @@ var ( ) func main() { - if len(os.Args) != 2 { + if len(os.Args) < 2 { usage() os.Exit(2) } 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 { + logError(err) + os.Exit(1) + } + return + case "pull": + if err := pullFiles(os.Args[2:]); err != nil { + logError(err) + os.Exit(1) + } + return } 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) } - 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, os.Stderr, 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 +105,20 @@ func main() { } func usage() { - fmt.Fprintf(os.Stderr, "usage: %s clean|smudge|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) { + logError(err) + os.Exit(1) +} + +func logError(err error) { + logging.New(os.Stderr).Error(err.Error(), "app", appName) } func versionOutput() string { diff --git a/main_test.go b/main_test.go index f628e60..c51f60a 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,175 @@ func TestVersionOutput(t *testing.T) { t.Fatalf("versionOutput() = %q, want %q", got, want) } } + +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" { + 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) + } + 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) + } + + 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") + 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) + } + 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) + } + 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 := 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") + 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)) +} + +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()) +}