diff --git a/.github/actions/package/action.yml b/.github/actions/package/action.yml new file mode 100644 index 0000000..244c1f7 --- /dev/null +++ b/.github/actions/package/action.yml @@ -0,0 +1,30 @@ +name: Package archive +description: Package a release binary with project metadata. + +inputs: + target: + description: Rust target triple used in the archive name. + required: true + +runs: + using: composite + steps: + - name: Package (unix) + if: runner.os != 'Windows' + shell: bash + run: | + name="diffs-${{ inputs.target }}" + mkdir -p "dist/${name}" + cp target/release/diffs "dist/${name}/" + cp LICENSE README.md "dist/${name}/" + tar -C "dist/${name}" -czf "dist/${name}.tar.gz" diffs LICENSE README.md + + - name: Package (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $name = "diffs-${{ inputs.target }}" + New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null + Copy-Item target/release/diffs.exe "dist/$name/" + Copy-Item LICENSE,README.md "dist/$name/" + Compress-Archive -Path "dist/$name/*" -DestinationPath "dist/$name.zip" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f69062..0dd8bfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,88 +13,151 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + CARGO_TERM_COLOR: always + jobs: - lint: - name: Lint + web: + name: Build web assets runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - - name: Setup Go - uses: actions/setup-go@v6 + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v6 with: - go-version-file: go.mod - cache-dependency-path: go.sum + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint web + run: pnpm --dir web lint + + - name: Build web + run: pnpm --dir web build - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 + - name: Upload web assets + uses: actions/upload-artifact@v7 with: - version: v2.12 + name: web-dist + path: web/dist + if-no-files-found: error + retention-days: 1 - test: - name: Test + fmt: + name: Format runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt - - name: Setup Node - uses: actions/setup-node@v6 + - name: Check formatting + run: cargo fmt --all --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + needs: web + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download web assets + uses: actions/download-artifact@v7 with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + name: web-dist + path: web/dist - - name: Setup Go - uses: actions/setup-go@v6 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable with: - go-version-file: go.mod - cache-dependency-path: go.sum + components: clippy - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Run clippy + run: cargo clippy --all-targets --all-features --locked -- -D warnings + + test: + name: Test + needs: web + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download web assets + uses: actions/download-artifact@v7 + with: + name: web-dist + path: web/dist + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 - name: Run tests - run: pnpm test + run: cargo test --all-targets --locked snapshot: - name: Snapshot build - runs-on: ubuntu-latest - needs: - - lint - - test + name: Release build (${{ matrix.target }}) + needs: web + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + - runner: windows-latest + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.runner }} steps: - name: Checkout uses: actions/checkout@v6 + + - name: Download web assets + uses: actions/download-artifact@v7 with: - fetch-depth: 0 + name: web-dist + path: web/dist - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + - name: Cache cargo + uses: Swatinem/rust-cache@v2 - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache-dependency-path: go.sum + - name: Build release binary + run: cargo build --release --locked - - name: Run GoReleaser snapshot - uses: goreleaser/goreleaser-action@v7 + - name: Package archive + uses: ./.github/actions/package with: - distribution: goreleaser - version: "~> v2" - args: release --snapshot --clean + target: ${{ matrix.target }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7bec95..aecd1cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,18 +6,30 @@ on: - "v*" permissions: - contents: write + contents: read + +env: + CARGO_TERM_COLOR: always jobs: - release: - name: Release + web: + name: Build web assets runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - with: - fetch-depth: 0 + + - name: Verify version matches tag + shell: bash + run: | + tag="${GITHUB_REF_NAME#v}" + crate="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" + if [ "$tag" != "$crate" ]; then + echo "::error::tag v$tag does not match Cargo.toml version $crate" + exit 1 + fi + echo "Releasing version $crate" - name: Setup pnpm uses: pnpm/action-setup@v6 @@ -29,24 +41,233 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache-dependency-path: go.sum - - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Lint web + run: pnpm --dir web lint + + - name: Build web + run: pnpm --dir web build + + - name: Upload web assets + uses: actions/upload-artifact@v7 + with: + name: web-dist + path: web/dist + if-no-files-found: error + retention-days: 1 + + check: + name: Check + needs: web + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download web assets + uses: actions/download-artifact@v7 + with: + name: web-dist + path: web/dist + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features --locked -- -D warnings + - name: Run tests - run: pnpm test + run: cargo test --all-targets --locked + + build: + name: Build ${{ matrix.target }} + needs: check + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - runner: macos-13 + target: x86_64-apple-darwin + - runner: macos-14 + target: aarch64-apple-darwin + - runner: windows-latest + target: x86_64-pc-windows-msvc + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.runner }} - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download web assets + uses: actions/download-artifact@v7 with: - distribution: goreleaser - version: "~> v2" - args: release --clean + name: web-dist + path: web/dist + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Build release binary + run: cargo build --release --locked + + - name: Package archive + uses: ./.github/actions/package + with: + target: ${{ matrix.target }} + + - name: Upload archive + uses: actions/upload-artifact@v7 + with: + name: archive-${{ matrix.target }} + path: | + dist/*.tar.gz + dist/*.zip + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + release: + name: Publish release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download archives + uses: actions/download-artifact@v7 + with: + pattern: archive-* + path: dist + merge-multiple: true + + - name: Generate checksums + shell: bash + working-directory: dist + run: | + shasum -a 256 * > checksums.txt + cat checksums.txt + + - name: Create GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --verify-tag \ + --generate-notes \ + dist/*.tar.gz \ + dist/*.zip \ + dist/checksums.txt + + homebrew: + name: Update Homebrew cask + needs: release + runs-on: ubuntu-latest + if: ${{ !contains(github.ref_name, '-') }} + + steps: + - name: Download archives + uses: actions/download-artifact@v7 + with: + pattern: archive-* + path: dist + merge-multiple: true + + - name: Render cask + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + sha_darwin_arm="$(shasum -a 256 "dist/diffs-aarch64-apple-darwin.tar.gz" | awk '{print $1}')" + sha_darwin_intel="$(shasum -a 256 "dist/diffs-x86_64-apple-darwin.tar.gz" | awk '{print $1}')" + sha_linux_arm="$(shasum -a 256 "dist/diffs-aarch64-unknown-linux-gnu.tar.gz" | awk '{print $1}')" + sha_linux_intel="$(shasum -a 256 "dist/diffs-x86_64-unknown-linux-gnu.tar.gz" | awk '{print $1}')" + mkdir -p out/Casks + cat > out/Casks/diffs.rb < +diffs UI screenshot ## Motivation @@ -43,8 +43,6 @@ diffs pr 123 # PR in the current repo diffs pr org/repo/pull/123 # PR in any repo ``` -diffs UI screenshot - Review the current branch against a base locally: ```sh @@ -107,7 +105,7 @@ Use `reply`, `resolve`, and `reopen` to update a thread. Pass `--dir /path/to/re ## Build from source -Requires Go 1.26+ and pnpm. +Requires Rust (current stable, 1.96+) and pnpm/Node. ```sh pnpm install diff --git a/assets/readme-gopher.jpg b/assets/readme-gopher.jpg deleted file mode 100644 index aa64c76..0000000 Binary files a/assets/readme-gopher.jpg and /dev/null differ diff --git a/cmd/diffs/branch.go b/cmd/diffs/branch.go deleted file mode 100644 index 59acd53..0000000 --- a/cmd/diffs/branch.go +++ /dev/null @@ -1,95 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "net/url" - "path/filepath" - "strings" - "time" - - "github.com/spf13/cobra" -) - -func newBranchCommand(opts *cliOptions, started time.Time) *cobra.Command { - var includeDirty bool - cmd := &cobra.Command{ - Use: "branch [base]", - Short: "Review commits on the current branch against a base", - Long: "Compare HEAD against a base ref (three-dot). With no argument, infers the base from the branch's PR, repo default, or main/master.", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - displayCWD, err := filepath.Abs(opts.dir) - if err != nil { - return err - } - if _, err := gitRoot(displayCWD); err != nil { - errOut := cmd.ErrOrStderr() - printLocalGitHelp(errOut, displayCWD, colorEnabled(errOut)) - _ = cmd.Help() - return quietError{err: err} - } - base, err := resolveBranchBase(args, opts.dir) - if err != nil { - return err - } - target := branchTarget(base, includeDirty) - return runServerTarget(cmd, opts, target, started) - }, - } - addServeFlags(cmd, opts, false) - cmd.Flags().BoolVar(&includeDirty, "include-dirty", false, "include staged, unstaged, and untracked changes") - return cmd -} - -func branchTarget(base string, includeDirty bool) string { - values := url.Values{} - values.Set("base", base) - if includeDirty { - values.Set("dirty", "1") - } - return "/branch?" + values.Encode() -} - -func resolveBranchBase(args []string, dir string) (string, error) { - if len(args) == 1 { - base := strings.TrimSpace(args[0]) - if base == "" { - return "", errors.New("base ref must not be empty") - } - return base, nil - } - ctx, cancel := context.WithTimeout(context.Background(), defaultGHTimeout) - defer cancel() - - if base, err := runGHPRBaseRef(ctx, dir); err == nil && base != "" { - if ref, ok := resolveLocalRef(dir, base); ok { - return ref, nil - } - } - if base, err := runGHRepoDefaultBranch(ctx, dir); err == nil && base != "" { - if ref, ok := resolveLocalRef(dir, base); ok { - return ref, nil - } - } - for _, candidate := range []string{"main", "master"} { - if ref, ok := resolveLocalRef(dir, candidate); ok { - return ref, nil - } - } - return "", fmt.Errorf("could not infer base ref; pass one explicitly, e.g. `diffs branch main`") -} - -// resolveLocalRef returns ref if it resolves to a commit locally, otherwise -// tries origin/. Inferred bases (PR base, default branch) may name -// branches that exist only as a remote-tracking ref in fresh clones. -func resolveLocalRef(dir, ref string) (string, bool) { - if gitRefExists(dir, ref) { - return ref, true - } - if candidate := "origin/" + ref; gitRefExists(dir, candidate) { - return candidate, true - } - return "", false -} diff --git a/cmd/diffs/browser.go b/cmd/diffs/browser.go deleted file mode 100644 index dc197a6..0000000 --- a/cmd/diffs/browser.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os/exec" - "runtime" - "strings" - "time" -) - -func openBrowser(url string) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.CommandContext(ctx, "open", url) - case "windows": - cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", url) - default: - cmd = exec.CommandContext(ctx, "xdg-open", url) - } - out, err := cmd.CombinedOutput() - if err != nil { - msg := strings.TrimSpace(string(out)) - if msg != "" { - return fmt.Errorf("%w: %s", err, msg) - } - return err - } - return nil -} diff --git a/cmd/diffs/comments.go b/cmd/diffs/comments.go deleted file mode 100644 index 6618e5a..0000000 --- a/cmd/diffs/comments.go +++ /dev/null @@ -1,216 +0,0 @@ -package main - -import ( - "fmt" - "io" - "strings" - "text/tabwriter" - - "github.com/imfing/diffs-cli/internal/comments" - "github.com/spf13/cobra" -) - -const ( - commentPreviewLimit = 72 - commentPreviewBodyLimit = commentPreviewLimit - len("...") -) - -type commentsOptions struct { - json bool -} - -func newCommentsCommand(opts *cliOptions) *cobra.Command { - commentOpts := &commentsOptions{} - cmd := &cobra.Command{ - Use: "comments", - Short: "Manage local review comments", - } - cmd.PersistentFlags().BoolVar(&commentOpts.json, "json", false, "write JSON output") - cmd.AddCommand( - newCommentsListCommand(opts, commentOpts), - newCommentsAddCommand(opts, commentOpts), - newCommentsReplyCommand(opts, commentOpts), - newCommentsResolveCommand(opts, commentOpts), - newCommentsReopenCommand(opts, commentOpts), - ) - return cmd -} - -func newCommentsListCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - return &cobra.Command{ - Use: "list", - Short: "List local comment threads for the current branch", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - return withCommentStore(opts, func(store *comments.Store) error { - threads, err := store.List(cmd.Context()) - if err != nil { - return err - } - if commentOpts.json { - return writeJSONCLI(cmd.OutOrStdout(), map[string]any{"threads": threads}) - } - printThreads(cmd.OutOrStdout(), threads) - return nil - }) - }, - } -} - -func newCommentsAddCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - var input comments.AddThreadInput - cmd := &cobra.Command{ - Use: "add --file PATH --line LINE [--end-line LINE] --body BODY", - Short: "Create a local comment thread", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - body, err := bodyFromFlag(cmd, input.Body) - if err != nil { - return err - } - input.Body = body - return withCommentStore(opts, func(store *comments.Store) error { - thread, err := store.AddThread(cmd.Context(), input) - if err != nil { - return err - } - return printThreadResult(cmd.OutOrStdout(), thread, commentOpts.json) - }) - }, - } - cmd.Flags().StringVar(&input.Path, "file", "", "repository-relative file path") - cmd.Flags().IntVar(&input.Line, "line", 0, "line number") - cmd.Flags().StringVar(&input.Side, "side", comments.DefaultSide, "diff side: additions or deletions") - cmd.Flags().IntVar(&input.EndLine, "end-line", 0, "end line number for a multi-line comment") - cmd.Flags().StringVar(&input.EndSide, "end-side", "", "end diff side for a multi-line comment: additions or deletions") - cmd.Flags().StringVar(&input.Body, "body", "", "comment body, or - to read stdin") - cmd.Flags().StringVar(&input.Author, "author", "", "comment author") - _ = cmd.MarkFlagRequired("file") - _ = cmd.MarkFlagRequired("line") - _ = cmd.MarkFlagRequired("body") - return cmd -} - -func newCommentsReplyCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - var input comments.AddReplyInput - cmd := &cobra.Command{ - Use: "reply THREAD_ID --body BODY", - Short: "Reply to a local comment thread", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - body, err := bodyFromFlag(cmd, input.Body) - if err != nil { - return err - } - input.Body = body - return withCommentStore(opts, func(store *comments.Store) error { - thread, err := store.AddReply(cmd.Context(), args[0], input) - if err != nil { - return err - } - return printThreadResult(cmd.OutOrStdout(), thread, commentOpts.json) - }) - }, - } - cmd.Flags().StringVar(&input.Body, "body", "", "reply body, or - to read stdin") - cmd.Flags().StringVar(&input.Author, "author", "", "reply author") - _ = cmd.MarkFlagRequired("body") - return cmd -} - -func newCommentsResolveCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - return &cobra.Command{ - Use: "resolve THREAD_ID", - Short: "Resolve a local comment thread", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return withCommentStore(opts, func(store *comments.Store) error { - thread, err := store.Resolve(cmd.Context(), args[0]) - if err != nil { - return err - } - return printThreadResult(cmd.OutOrStdout(), thread, commentOpts.json) - }) - }, - } -} - -func newCommentsReopenCommand(opts *cliOptions, commentOpts *commentsOptions) *cobra.Command { - return &cobra.Command{ - Use: "reopen THREAD_ID", - Short: "Reopen a resolved local comment thread", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return withCommentStore(opts, func(store *comments.Store) error { - thread, err := store.Reopen(cmd.Context(), args[0]) - if err != nil { - return err - } - return printThreadResult(cmd.OutOrStdout(), thread, commentOpts.json) - }) - }, - } -} - -func withCommentStore(opts *cliOptions, fn func(*comments.Store) error) error { - store, err := comments.NewStore(opts.dir) - if err != nil { - return err - } - return fn(store) -} - -func bodyFromFlag(cmd *cobra.Command, body string) (string, error) { - if body != "-" { - return body, nil - } - data, err := io.ReadAll(cmd.InOrStdin()) - if err != nil { - return "", err - } - return string(data), nil -} - -func printThreadResult(w io.Writer, thread comments.Thread, asJSON bool) error { - if asJSON { - return writeJSONCLI(w, thread) - } - _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", thread.ID, thread.Status, threadLocation(thread), latestCommentBody(thread)) - return err -} - -func printThreads(w io.Writer, threads []comments.Thread) { - if len(threads) == 0 { - _, _ = fmt.Fprintln(w, "No local comment threads.") - return - } - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(tw, "ID\tSTATUS\tLOCATION\tCOMMENTS\tLATEST") - for _, thread := range threads { - _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\n", thread.ID, thread.Status, threadLocation(thread), len(thread.Comments), latestCommentBody(thread)) - } - _ = tw.Flush() -} - -func threadLocation(thread comments.Thread) string { - endLine := thread.EndLine - if endLine == 0 { - endLine = thread.Line - } - if endLine == thread.Line { - return fmt.Sprintf("%s:%d", thread.Path, thread.Line) - } - return fmt.Sprintf("%s:%d-%d", thread.Path, thread.Line, endLine) -} - -func latestCommentBody(thread comments.Thread) string { - if len(thread.Comments) == 0 { - return "" - } - body := strings.ReplaceAll(thread.Comments[len(thread.Comments)-1].Body, "\n", " ") - runes := []rune(body) - if len(runes) > commentPreviewLimit { - return string(runes[:commentPreviewBodyLimit]) + "..." - } - return body -} diff --git a/cmd/diffs/gh.go b/cmd/diffs/gh.go deleted file mode 100644 index 4f9a261..0000000 --- a/cmd/diffs/gh.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "os/exec" - "strings" - "time" -) - -const defaultGHTimeout = 10 * time.Second - -var runGHPRView = func(ctx context.Context, dir string) (string, error) { - return runGH(ctx, dir, "pr", "view", "--json", "url", "-q", ".url") -} - -var runGHPRBaseRef = func(ctx context.Context, dir string) (string, error) { - return runGH(ctx, dir, "pr", "view", "--json", "baseRefName", "-q", ".baseRefName") -} - -var runGHRepoDefaultBranch = func(ctx context.Context, dir string) (string, error) { - return runGH(ctx, dir, "repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name") -} - -func runGH(ctx context.Context, dir string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "gh", args...) - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { - return "", errors.New(strings.TrimSpace(string(exitErr.Stderr))) - } - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -func currentBranchPRURL(dir string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), defaultGHTimeout) - defer cancel() - url, err := runGHPRView(ctx, dir) - if err != nil { - return "", fmt.Errorf("resolve PR for current branch: %w\nhint: open a PR for this branch, or pass `diffs pr `", err) - } - if url == "" { - return "", errors.New("no pull request found for the current branch") - } - return url, nil -} diff --git a/cmd/diffs/git.go b/cmd/diffs/git.go deleted file mode 100644 index 7d039f8..0000000 --- a/cmd/diffs/git.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "net/url" - "strings" - - gitcmd "github.com/imfing/diffs-cli/internal/git" -) - -var errNotGitRepository = errors.New("not a git repository") - -func targetLabel(targetPath, cwd string) string { - if targetPath == "/local" { - if branch := gitBranch(cwd); branch != "" { - return branch - } - return "local repository" - } - if strings.HasPrefix(targetPath, "/branch") { - base := branchBaseFromTargetPath(targetPath) - head := gitBranch(cwd) - if head == "" { - head = "HEAD" - } - if base == "" { - return fmt.Sprintf("%s branch diff", head) - } - return fmt.Sprintf("%s -> %s", head, base) - } - parts := strings.Split(strings.Trim(targetPath, "/"), "/") - if len(parts) == 4 && parts[2] == "pull" { - return fmt.Sprintf("GitHub PR %s/%s#%s", parts[0], parts[1], parts[3]) - } - return targetPath -} - -func branchBaseFromTargetPath(targetPath string) string { - queryStart := strings.IndexByte(targetPath, '?') - if queryStart < 0 { - return "" - } - values, err := url.ParseQuery(targetPath[queryStart+1:]) - if err != nil { - return "" - } - return values.Get("base") -} - -func gitRoot(cwd string) (string, error) { - ctx, cancel := gitCtx() - defer cancel() - - root, err := gitcmd.Root(ctx, cwd) - if err != nil { - return "", fmt.Errorf("%w: %s", errNotGitRepository, cwd) - } - return root, nil -} - -func gitBranch(cwd string) string { - ctx, cancel := gitCtx() - defer cancel() - return gitcmd.Branch(ctx, cwd) -} - -func gitRefExists(cwd, ref string) bool { - ctx, cancel := gitCtx() - defer cancel() - return gitcmd.OK(ctx, cwd, "rev-parse", "--verify", "--quiet", ref+"^{commit}") -} - -func gitRemoteURL(cwd, name string) (string, error) { - ctx, cancel := gitCtx() - defer cancel() - out, err := gitcmd.Run(ctx, cwd, "remote", "get-url", name) - if err != nil { - return "", fmt.Errorf("get git remote %q URL: %w", name, err) - } - return strings.TrimSpace(string(out)), nil -} - -func gitCtx() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), gitcmd.DefaultTimeout) -} diff --git a/cmd/diffs/json.go b/cmd/diffs/json.go deleted file mode 100644 index 3615a46..0000000 --- a/cmd/diffs/json.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "encoding/json" - "io" -) - -func writeJSONCLI(w io.Writer, v any) error { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(v) -} diff --git a/cmd/diffs/main.go b/cmd/diffs/main.go deleted file mode 100644 index 0ef2210..0000000 --- a/cmd/diffs/main.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - "github.com/spf13/cobra" -) - -func main() { - if err := executeRootCommand(newRootCommand(time.Now())); err != nil { - var quiet quietError - if !errors.As(err, &quiet) { - _, _ = fmt.Fprintln(os.Stderr, err) - } - os.Exit(1) - } -} - -func executeRootCommand(cmd *cobra.Command) error { - err := cmd.Execute() - if err == nil || !isUnknownCommandError(err) { - return err - } - - errOut := cmd.ErrOrStderr() - _, _ = fmt.Fprintln(errOut, err) - _, _ = fmt.Fprintln(errOut) - cmd.SetOut(errOut) - _ = cmd.Help() - return quietError{err: err} -} - -func isUnknownCommandError(err error) bool { - return strings.HasPrefix(err.Error(), "unknown command ") -} diff --git a/cmd/diffs/main_test.go b/cmd/diffs/main_test.go deleted file mode 100644 index e051a46..0000000 --- a/cmd/diffs/main_test.go +++ /dev/null @@ -1,937 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - "unicode/utf8" - - "github.com/imfing/diffs-cli/internal/comments" - "github.com/imfing/diffs-cli/internal/server" -) - -func TestMain(m *testing.M) { - for k, v := range map[string]string{ - "GIT_AUTHOR_NAME": "Test", - "GIT_AUTHOR_EMAIL": "test@example.com", - "GIT_COMMITTER_NAME": "Test", - "GIT_COMMITTER_EMAIL": "test@example.com", - } { - _ = os.Setenv(k, v) - } - os.Exit(m.Run()) -} - -func TestPRTargetFromArgsPath(t *testing.T) { - tests := []struct { - name string - args []string - want string - }{ - {name: "path", args: []string{"/org/repo/pull/123"}, want: "/org/repo/pull/123"}, - {name: "path without leading slash", args: []string{"org/repo/pull/123"}, want: "/org/repo/pull/123"}, - {name: "url", args: []string{"https://github.com/org/repo/pull/123"}, want: "/org/repo/pull/123"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := prTargetFromArgs(tt.args) - if err != nil { - t.Fatalf("prTargetFromArgs() error = %v", err) - } - if got.Path != tt.want { - t.Fatalf("prTargetFromArgs().Path = %q, want %q", got.Path, tt.want) - } - }) - } -} - -func TestPRTargetFromArgsIncludesURLHost(t *testing.T) { - tests := []struct { - name string - args []string - wantPath string - wantHost string - }{ - {name: "path", args: []string{"/org/repo/pull/123"}, wantPath: "/org/repo/pull/123"}, - {name: "path without leading slash", args: []string{"org/repo/pull/123"}, wantPath: "/org/repo/pull/123"}, - {name: "github url", args: []string{"https://github.com/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.com"}, - {name: "enterprise url", args: []string{"https://github.example.com/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "enterprise url with port", args: []string{"https://github.example.com:8443/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "mixed case host", args: []string{"https://GITHUB.example.com/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "uppercase scheme", args: []string{"HTTPS://github.example.com/org/repo/pull/123"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "files subpage", args: []string{"https://github.example.com/org/repo/pull/123/files"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "commits subpage", args: []string{"https://github.example.com/org/repo/pull/123/commits"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "checks subpage", args: []string{"https://github.example.com/org/repo/pull/123/checks"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - {name: "reviews subpage", args: []string{"https://github.example.com/org/repo/pull/123/reviews"}, wantPath: "/org/repo/pull/123", wantHost: "github.example.com"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := prTargetFromArgs(tt.args) - if err != nil { - t.Fatalf("prTargetFromArgs() error = %v", err) - } - if got.Path != tt.wantPath || got.Host != tt.wantHost { - t.Fatalf("prTargetFromArgs() = %+v, want path %q host %q", got, tt.wantPath, tt.wantHost) - } - }) - } -} - -func TestResolvePRTargetFromArgsUsesCurrentRepositoryForNumber(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - git(t, dir, "remote", "add", "origin", "git@github.example.com:org/repo.git") - - got, err := resolvePRTargetFromArgs([]string{"123"}, dir) - if err != nil { - t.Fatalf("resolvePRTargetFromArgs() error = %v", err) - } - if got.Path != "/org/repo/pull/123" || got.Host != "github.example.com" { - t.Fatalf("resolvePRTargetFromArgs() = %+v, want path %q host %q", got, "/org/repo/pull/123", "github.example.com") - } -} - -func TestResolvePRTargetFromArgsKeepsExplicitTarget(t *testing.T) { - got, err := resolvePRTargetFromArgs([]string{"https://github.com/org/repo/pull/123"}, t.TempDir()) - if err != nil { - t.Fatalf("resolvePRTargetFromArgs() error = %v", err) - } - if got.Path != "/org/repo/pull/123" || got.Host != "github.com" { - t.Fatalf("resolvePRTargetFromArgs() = %+v, want path %q host %q", got, "/org/repo/pull/123", "github.com") - } -} - -func TestResolvePRTargetFromArgsNoArgsUsesGH(t *testing.T) { - dir := t.TempDir() - stubGHPRView(t, func(_ context.Context, gotDir string) (string, error) { - if gotDir != dir { - t.Errorf("runGHPRView dir = %q, want %q", gotDir, dir) - } - return "https://github.example.com/org/repo/pull/456\n", nil - }) - - got, err := resolvePRTargetFromArgs(nil, dir) - if err != nil { - t.Fatalf("resolvePRTargetFromArgs() error = %v", err) - } - if got.Path != "/org/repo/pull/456" || got.Host != "github.example.com" { - t.Fatalf("resolvePRTargetFromArgs() = %+v, want path %q host %q", got, "/org/repo/pull/456", "github.example.com") - } -} - -func TestResolvePRTargetFromArgsNoArgsErrorsWhenGHFails(t *testing.T) { - stubGHPRView(t, func(context.Context, string) (string, error) { - return "", errors.New("no pull requests found for branch \"feat/x\"") - }) - - _, err := resolvePRTargetFromArgs(nil, t.TempDir()) - if err == nil { - t.Fatal("resolvePRTargetFromArgs() succeeded, want error") - } - if !strings.Contains(err.Error(), "no pull requests found") { - t.Fatalf("resolvePRTargetFromArgs() error = %v, want gh stderr forwarded", err) - } -} - -func TestResolvePRTargetFromArgsNoArgsErrorsWhenURLEmpty(t *testing.T) { - stubGHPRView(t, func(context.Context, string) (string, error) { - return "", nil - }) - - _, err := resolvePRTargetFromArgs(nil, t.TempDir()) - if err == nil { - t.Fatal("resolvePRTargetFromArgs() succeeded, want error") - } - if !strings.Contains(err.Error(), "no pull request found") { - t.Fatalf("resolvePRTargetFromArgs() error = %v", err) - } -} - -func stubGHPRView(t *testing.T, fn func(context.Context, string) (string, error)) { - t.Helper() - orig := runGHPRView - runGHPRView = fn - t.Cleanup(func() { runGHPRView = orig }) -} - -func stubGHPRBaseRef(t *testing.T, fn func(context.Context, string) (string, error)) { - t.Helper() - orig := runGHPRBaseRef - runGHPRBaseRef = fn - t.Cleanup(func() { runGHPRBaseRef = orig }) -} - -func stubGHRepoDefaultBranch(t *testing.T, fn func(context.Context, string) (string, error)) { - t.Helper() - orig := runGHRepoDefaultBranch - runGHRepoDefaultBranch = fn - t.Cleanup(func() { runGHRepoDefaultBranch = orig }) -} - -func ghFails(err string) func(context.Context, string) (string, error) { - return func(context.Context, string) (string, error) { return "", errors.New(err) } -} - -func TestResolveBranchBaseUsesExplicitArgument(t *testing.T) { - stubGHPRBaseRef(t, ghFails("should not be called")) - stubGHRepoDefaultBranch(t, ghFails("should not be called")) - - got, err := resolveBranchBase([]string{"release/v2"}, t.TempDir()) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "release/v2" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "release/v2") - } -} - -func TestBranchTargetIncludesDirtyFlag(t *testing.T) { - if got := branchTarget("origin/main", false); got != "/branch?base=origin%2Fmain" { - t.Fatalf("branchTarget(clean) = %q", got) - } - if got := branchTarget("origin/main", true); got != "/branch?base=origin%2Fmain&dirty=1" { - t.Fatalf("branchTarget(dirty) = %q", got) - } -} - -func TestResolveBranchBasePrefersPRBase(t *testing.T) { - stubGHPRBaseRef(t, func(context.Context, string) (string, error) { return "develop", nil }) - stubGHRepoDefaultBranch(t, ghFails("should not be called")) - - dir := repoWithLocalBranches(t, "develop") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "develop" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "develop") - } -} - -func TestResolveBranchBaseFallsBackToRepoDefault(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR for branch")) - stubGHRepoDefaultBranch(t, func(context.Context, string) (string, error) { return "trunk", nil }) - - dir := repoWithLocalBranches(t, "trunk") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "trunk" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "trunk") - } -} - -func TestResolveBranchBaseFallsBackToOriginRefWhenLocalMissing(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR")) - stubGHRepoDefaultBranch(t, func(context.Context, string) (string, error) { return "main", nil }) - - dir := repoWithOriginBranch(t, "main") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "origin/main" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "origin/main") - } -} - -func TestResolveBranchBaseSkipsInferredRefThatDoesNotResolve(t *testing.T) { - stubGHPRBaseRef(t, func(context.Context, string) (string, error) { return "ghost", nil }) - stubGHRepoDefaultBranch(t, func(context.Context, string) (string, error) { return "trunk", nil }) - - dir := repoWithLocalBranches(t, "trunk") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "trunk" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "trunk") - } -} - -func TestResolveBranchBaseFallsBackToMainWhenExists(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR")) - stubGHRepoDefaultBranch(t, ghFails("not a repo")) - - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - if err := os.WriteFile(filepath.Join(dir, "f"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "f") - git(t, dir, "commit", "-m", "init") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "main" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "main") - } -} - -func TestResolveBranchBaseFallsBackToMasterWhenMainMissing(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR")) - stubGHRepoDefaultBranch(t, ghFails("not a repo")) - - dir := t.TempDir() - git(t, dir, "init", "-b", "master") - if err := os.WriteFile(filepath.Join(dir, "f"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "f") - git(t, dir, "commit", "-m", "init") - - got, err := resolveBranchBase(nil, dir) - if err != nil { - t.Fatalf("resolveBranchBase() error = %v", err) - } - if got != "master" { - t.Fatalf("resolveBranchBase() = %q, want %q", got, "master") - } -} - -func TestResolveBranchBaseErrorsWhenNothingResolves(t *testing.T) { - stubGHPRBaseRef(t, ghFails("no PR")) - stubGHRepoDefaultBranch(t, ghFails("not a repo")) - - dir := t.TempDir() - git(t, dir, "init", "-b", "feature") - - _, err := resolveBranchBase(nil, dir) - if err == nil { - t.Fatal("resolveBranchBase() succeeded, want error") - } - if !strings.Contains(err.Error(), "could not infer base") { - t.Fatalf("resolveBranchBase() error = %v", err) - } -} - -func TestRepoFromRemoteURL(t *testing.T) { - tests := []struct { - name string - remote string - wantHost string - wantOwner string - wantRepo string - wantErr bool - }{ - {name: "https", remote: "https://github.com/org/repo.git", wantHost: "github.com", wantOwner: "org", wantRepo: "repo"}, - {name: "ssh scp", remote: "git@github.com:org/repo.git", wantHost: "github.com", wantOwner: "org", wantRepo: "repo"}, - {name: "ssh url", remote: "ssh://git@github.example.com/org/repo.git", wantHost: "github.example.com", wantOwner: "org", wantRepo: "repo"}, - {name: "missing repo", remote: "https://github.com/org", wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := repoFromRemoteURL(tt.remote) - if tt.wantErr { - if err == nil { - t.Fatal("repoFromRemoteURL() succeeded, want error") - } - return - } - if err != nil { - t.Fatalf("repoFromRemoteURL() error = %v", err) - } - if got.Host != tt.wantHost || got.Owner != tt.wantOwner || got.Name != tt.wantRepo { - t.Fatalf("repoFromRemoteURL() = %+v, want host %q owner %q repo %q", got, tt.wantHost, tt.wantOwner, tt.wantRepo) - } - }) - } -} - -func TestResolveGitHubHostPrefersURLHostWhenFlagOmitted(t *testing.T) { - cmd := newRootCommand(time.Time{}) - prCmd, _, err := cmd.Find([]string{"pr"}) - if err != nil { - t.Fatal(err) - } - opts := &cliOptions{ghHost: "github.com"} - - got := opts.withResolvedGitHubHost(prCmd, "github.example.com") - if got.ghHost != "github.example.com" { - t.Fatalf("ghHost = %q, want URL host", got.ghHost) - } - if opts.ghHost != "github.com" { - t.Fatalf("original ghHost mutated to %q", opts.ghHost) - } -} - -func TestResolveGitHubHostKeepsExplicitFlag(t *testing.T) { - cmd := newRootCommand(time.Time{}) - prCmd, _, err := cmd.Find([]string{"pr"}) - if err != nil { - t.Fatal(err) - } - if err := prCmd.Flags().Set("gh-host", "explicit.example.com"); err != nil { - t.Fatal(err) - } - opts := &cliOptions{ghHost: "explicit.example.com"} - - got := opts.withResolvedGitHubHost(prCmd, "url.example.com") - if got.ghHost != "explicit.example.com" { - t.Fatalf("ghHost = %q, want explicit flag host", got.ghHost) - } -} - -func TestTargetPathFromArgsRejectsInvalidTarget(t *testing.T) { - tests := [][]string{ - nil, - {""}, - {"org/repo/issues/123"}, - {"http:///org/repo/pull/123"}, - {"https://github.example.com/org/repo/pull/123/random"}, - {"https://github.example.com/org/repo/pull/123/files/1"}, - } - for _, args := range tests { - if _, err := prTargetFromArgs(args); err == nil { - t.Fatalf("prTargetFromArgs(%v) succeeded, want error", args) - } - } -} - -func TestRootCommandRejectsDirectPRTarget(t *testing.T) { - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"/org/repo/pull/123"}) - if err := cmd.Execute(); err == nil { - t.Fatal("root command accepted direct PR target, want explicit pr subcommand") - } -} - -func TestUnknownCommandPrintsRootHelp(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&out) - cmd.SetArgs([]string{"bogus"}) - - err := executeRootCommand(cmd) - if err == nil { - t.Fatal("unknown command succeeded, want error") - } - var quiet quietError - if !errors.As(err, &quiet) { - t.Fatalf("error = %T, want quietError", err) - } - - got := out.String() - for _, want := range []string{ - `unknown command "bogus" for "diffs"`, - "Usage:", - "diffs [flags]", - "Available Commands:", - "branch", - "pr", - "version", - } { - if !strings.Contains(got, want) { - t.Fatalf("unknown command output missing %q in:\n%s", want, got) - } - } -} - -func TestLocalCommandRejectsNonGitRepository(t *testing.T) { - dir := t.TempDir() - var errOut bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&errOut) - cmd.SetErr(&errOut) - cmd.SetArgs([]string{"--dir", dir, "--no-open"}) - err := cmd.Execute() - if err == nil { - t.Fatal("local command succeeded outside git repository") - } - if !strings.Contains(err.Error(), "not a git repository") { - t.Fatalf("error = %v, want not a git repository", err) - } - got := errOut.String() - for _, want := range []string{ - "error not a git repository: " + dir, - "hint run from a git repository", - "hint or pass --dir /path/to/repo", - "hint or use diffs pr /org/repo/pull/123", - "Usage:", - "diffs [flags]", - "Available Commands:", - "branch", - "pr", - "--dir string", - } { - if !strings.Contains(got, want) { - t.Fatalf("git help missing %q in:\n%s", want, got) - } - } -} - -func TestBranchCommandRejectsNonGitRepository(t *testing.T) { - for _, args := range [][]string{ - {"--dir", t.TempDir(), "branch", "main", "--no-open"}, - {"--dir", t.TempDir(), "branch", "--no-open"}, - } { - t.Run(strings.Join(args, " "), func(t *testing.T) { - var errOut bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&errOut) - cmd.SetErr(&errOut) - cmd.SetArgs(args) - err := cmd.Execute() - if err == nil { - t.Fatal("branch command succeeded outside git repository") - } - if !strings.Contains(err.Error(), "not a git repository") { - t.Fatalf("error = %v, want not a git repository", err) - } - got := errOut.String() - for _, want := range []string{ - "error not a git repository: " + args[1], - "hint run from a git repository", - "Usage:", - "diffs branch [base]", - } { - if !strings.Contains(got, want) { - t.Fatalf("branch git help missing %q in:\n%s", want, got) - } - } - }) - } -} - -func TestGitRootAcceptsGitRepository(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - - got, err := gitRoot(dir) - if err != nil { - t.Fatalf("gitRoot() error = %v", err) - } - want, err := filepath.EvalSymlinks(dir) - if err != nil { - t.Fatalf("EvalSymlinks() error = %v", err) - } - if got != want { - t.Fatalf("gitRoot() = %q, want %q", got, want) - } -} - -func TestNormalizeListenAddrPrefersIPv4Loopback(t *testing.T) { - if got := normalizeListenAddr("localhost:3433"); got != "127.0.0.1:3433" { - t.Fatalf("normalizeListenAddr() = %q, want %q", got, "127.0.0.1:3433") - } -} - -func TestListenAddrFromOptionsUsesHostAndPort(t *testing.T) { - got, err := listenAddrFromOptions("localhost", 4321) - if err != nil { - t.Fatalf("listenAddrFromOptions() error = %v", err) - } - if got != "127.0.0.1:4321" { - t.Fatalf("listenAddrFromOptions() = %q, want %q", got, "127.0.0.1:4321") - } -} - -func TestListenAddrFromOptionsRejectsInvalidPort(t *testing.T) { - if _, err := listenAddrFromOptions("127.0.0.1", 70000); err == nil { - t.Fatal("expected invalid port to fail") - } -} - -func TestListenWithPortFallbackUsesRandomPortWhenBusy(t *testing.T) { - occupied, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen occupied port: %v", err) - } - defer func() { _ = occupied.Close() }() - - ln, fallback, err := listenWithPortFallback(occupied.Addr().String()) - if err != nil { - t.Fatalf("listenWithPortFallback() error = %v", err) - } - defer func() { _ = ln.Close() }() - if fallback == nil { - t.Fatal("listenWithPortFallback() fallback = nil, want fallback") - } - if fallback.Requested != occupied.Addr().String() { - t.Fatalf("fallback requested = %q, want %q", fallback.Requested, occupied.Addr().String()) - } - if fallback.Actual != ln.Addr().String() { - t.Fatalf("fallback actual = %q, want %q", fallback.Actual, ln.Addr().String()) - } - if fallback.Actual == fallback.Requested { - t.Fatalf("fallback reused busy address %q", fallback.Actual) - } -} - -func TestBrowserURLUsesLoopbackForWildcard(t *testing.T) { - got := browserURL(&net.TCPAddr{IP: net.IPv4zero, Port: 3433}, "/local") - if got != "http://127.0.0.1:3433/local" { - t.Fatalf("browserURL() = %q, want %q", got, "http://127.0.0.1:3433/local") - } -} - -func TestIsLocalGitTarget(t *testing.T) { - tests := []struct { - path string - want bool - }{ - {path: "/local", want: true}, - {path: "/branch", want: true}, - {path: "/branch?base=main", want: true}, - {path: "/org/repo/pull/123", want: false}, - {path: "/branching", want: false}, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - if got := isLocalGitTarget(tt.path); got != tt.want { - t.Fatalf("isLocalGitTarget() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestTargetLabel(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - git(t, dir, "checkout", "-b", "feature/startup") - - tests := []struct { - path string - cwd string - want string - }{ - {path: "/local", cwd: dir, want: "feature/startup"}, - {path: "/branch?base=origin%2Fmain", cwd: dir, want: "feature/startup -> origin/main"}, - {path: "/org/repo/pull/123", cwd: dir, want: "GitHub PR org/repo#123"}, - {path: "/local", cwd: filepath.Join(dir, "missing"), want: "local repository"}, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - if got := targetLabel(tt.path, tt.cwd); got != tt.want { - t.Fatalf("targetLabel() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestPrintStartup(t *testing.T) { - var out bytes.Buffer - printStartup(&out, startupInfo{ - URL: "http://127.0.0.1:3433/local", - Target: "feature/startup", - CWD: "/repo", - Watching: true, - Elapsed: 12 * time.Millisecond, - }, false) - - got := out.String() - for _, want := range []string{ - "diffs ready in 12 ms", - "serve http://127.0.0.1:3433/local", - "target feature/startup", - "watch /repo", - "stop Ctrl+C", - } { - if !strings.Contains(got, want) { - t.Fatalf("printStartup() missing %q in:\n%s", want, got) - } - } -} - -func TestPrintPortFallback(t *testing.T) { - var out bytes.Buffer - printPortFallback(&out, "127.0.0.1:3433", "127.0.0.1:52624", false) - got := out.String() - if !strings.Contains(got, "warn 127.0.0.1:3433 in use; using 127.0.0.1:52624") { - t.Fatalf("printPortFallback() = %q", got) - } -} - -func git(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) - } -} - -func repoWithLocalBranches(t *testing.T, branches ...string) string { - t.Helper() - if len(branches) == 0 { - t.Fatal("repoWithLocalBranches requires at least one branch name") - } - dir := t.TempDir() - git(t, dir, "init", "-b", branches[0]) - if err := os.WriteFile(filepath.Join(dir, "seed"), []byte("seed\n"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "seed") - git(t, dir, "commit", "-m", "init") - for _, b := range branches[1:] { - git(t, dir, "branch", b) - } - return dir -} - -func repoWithOriginBranch(t *testing.T, branch string) string { - t.Helper() - dir := t.TempDir() - git(t, dir, "init", "-b", "feature") - if err := os.WriteFile(filepath.Join(dir, "seed"), []byte("seed\n"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "seed") - git(t, dir, "commit", "-m", "init") - git(t, dir, "update-ref", "refs/remotes/origin/"+branch, "HEAD") - return dir -} - -func TestPrintReload(t *testing.T) { - var out bytes.Buffer - printReload(&out, time.Date(2026, 5, 23, 14, 15, 16, 0, time.Local), []server.ChangedFile{{Action: server.ChangeModified, Path: "web/src/App.tsx"}}, false) - - got := out.String() - for _, want := range []string{"modified web/src/App.tsx"} { - if !strings.Contains(got, want) { - t.Fatalf("printReload() missing %q in %q", want, got) - } - } - if strings.Contains(got, "(+") || strings.Contains(got, " -") { - t.Fatalf("printReload() should not include line stats: %q", got) - } - if strings.Contains(got, "14:15:16") || strings.Contains(got, "[diffs]") || strings.Contains(got, "reload") || strings.Contains(got, "change") { - t.Fatalf("printReload() should not include timestamp, bracketed prefix, or extra reload line: %q", got) - } -} - -func TestReloadLoggerCoalescesBursts(t *testing.T) { - var out bytes.Buffer - reload := newReloadLogger(&out, false) - now := time.Date(2026, 5, 23, 14, 15, 16, 0, time.Local) - - reload(now, []server.ChangedFile{{Action: server.ChangeModified, Path: "one.go"}}) - reload(now.Add(100*time.Millisecond), []server.ChangedFile{{Action: server.ChangeModified, Path: "two.go"}}) - reload(now.Add(600*time.Millisecond), []server.ChangedFile{{Action: server.ChangeModified, Path: "three.go"}}) - - if got := strings.Count(out.String(), "modified"); got != 2 { - t.Fatalf("reload log count = %d, want 2:\n%s", got, out.String()) - } -} - -func TestReloadLineSummarizesMultiplePaths(t *testing.T) { - label, message := reloadLine([]server.ChangedFile{ - {Action: server.ChangeAdded, Path: "a.go"}, - {Action: server.ChangeModified, Path: "b.go"}, - {Action: server.ChangeDeleted, Path: "c.go"}, - }, terminalColors{}, false) - if label != "added" || message != "a.go (+2 more)" { - t.Fatalf("reloadLine() = %q, %q; want added, a.go (+2 more)", label, message) - } -} - -func TestReloadLineColorsPath(t *testing.T) { - c := terminalColors{cyan: "C", reset: "Z"} - label, message := reloadLine([]server.ChangedFile{ - {Action: server.ChangeModified, Path: "a.go"}, - }, c, true) - want := "Ca.goZ" - if label != "modified" || message != want { - t.Fatalf("reloadLine() = %q, %q; want modified, %q", label, message, want) - } -} - -func TestReloadLineFallsBackToChangeLabel(t *testing.T) { - label, message := reloadLine([]server.ChangedFile{ - {Path: "a.go"}, - {Path: "b.go"}, - {Path: "c.go"}, - }, terminalColors{}, false) - want := "a.go (+2 more)" - if label != "change" || message != want { - t.Fatalf("reloadLine() = %q, %q; want change, %q", label, message, want) - } -} - -func TestLatestCommentBodyTruncatesUTF8Safely(t *testing.T) { - body := strings.Repeat("评", 80) + " done" - got := latestCommentBody(comments.Thread{ - Comments: []comments.Comment{{Body: body}}, - }) - if !utf8.ValidString(got) { - t.Fatalf("latestCommentBody() returned invalid UTF-8: %q", got) - } - if strings.Count(got, "评") != 69 || !strings.HasSuffix(got, "...") { - t.Fatalf("latestCommentBody() = %q, want 69 runes plus ellipsis", got) - } -} - -func TestRootCommandHelpShowsSubcommandsAndDir(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&out) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--help"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("help failed: %v", err) - } - - got := out.String() - for _, want := range []string{ - "diffs [flags]", - "branch", - "pr", - "version", - "--dir string", - } { - if !strings.Contains(got, want) { - t.Fatalf("help output missing %q in:\n%s", want, got) - } - } - if strings.Contains(got, "--github-host") { - t.Fatalf("root help output should not include pr-only flag --github-host:\n%s", got) - } - if strings.Contains(got, "--gh-host") { - t.Fatalf("root help output should not include pr-only flag --gh-host:\n%s", got) - } -} - -func TestPRCommandHelp(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&out) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"pr", "--help"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("pr help failed: %v", err) - } - - got := out.String() - for _, want := range []string{ - "diffs pr [number|github-pr-url|/org/repo/pull/123]", - "--host string", - "--gh-host string", - "--port int", - "--dir string", - } { - if !strings.Contains(got, want) { - t.Fatalf("pr help output missing %q in:\n%s", want, got) - } - } - if strings.Contains(got, "--github-host") { - t.Fatalf("pr help output should not include removed flag --github-host:\n%s", got) - } -} - -func TestBranchCommandHelp(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&out) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"branch", "--help"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("branch help failed: %v", err) - } - - got := out.String() - for _, want := range []string{ - "diffs branch [base]", - "--include-dirty", - "--host string", - "--port int", - "--dir string", - } { - if !strings.Contains(got, want) { - t.Fatalf("branch help output missing %q in:\n%s", want, got) - } - } -} - -func TestVersionCommandPrintsDefaultDevVersion(t *testing.T) { - var out bytes.Buffer - cmd := newRootCommand(time.Time{}) - cmd.SetOut(&out) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"version"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("version failed: %v", err) - } - - if got := out.String(); got != "dev\n" { - t.Fatalf("version output = %q, want %q", got, "dev\n") - } -} - -func TestCommentsCommandAddAndListJSON(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - - var addOut bytes.Buffer - addCmd := newRootCommand(time.Time{}) - addCmd.SetOut(&addOut) - addCmd.SetErr(&bytes.Buffer{}) - addCmd.SetArgs([]string{ - "--dir", dir, - "comments", "--json", "add", - "--file", "web/src/App.tsx", - "--line", "42", - "--body", "Looks suspicious", - "--author", "agent", - }) - if err := addCmd.Execute(); err != nil { - t.Fatalf("comments add failed: %v", err) - } - var added struct { - ID string `json:"id"` - Path string `json:"path"` - Line int `json:"line"` - Status string `json:"status"` - Comments []struct { - Author string `json:"author"` - Body string `json:"body"` - } `json:"comments"` - } - if err := json.Unmarshal(addOut.Bytes(), &added); err != nil { - t.Fatalf("decode add json: %v\n%s", err, addOut.String()) - } - if added.ID == "" || added.Path != "web/src/App.tsx" || added.Line != 42 || added.Status != "open" { - t.Fatalf("unexpected added thread: %+v", added) - } - if len(added.Comments) != 1 || added.Comments[0].Author != "agent" || added.Comments[0].Body != "Looks suspicious" { - t.Fatalf("unexpected added comments: %+v", added.Comments) - } - - var listOut bytes.Buffer - listCmd := newRootCommand(time.Time{}) - listCmd.SetOut(&listOut) - listCmd.SetErr(&bytes.Buffer{}) - listCmd.SetArgs([]string{"--dir", dir, "comments", "--json", "list"}) - if err := listCmd.Execute(); err != nil { - t.Fatalf("comments list failed: %v", err) - } - var listed struct { - Threads []struct { - ID string `json:"id"` - } `json:"threads"` - } - if err := json.Unmarshal(listOut.Bytes(), &listed); err != nil { - t.Fatalf("decode list json: %v\n%s", err, listOut.String()) - } - if len(listed.Threads) != 1 || listed.Threads[0].ID != added.ID { - t.Fatalf("listed threads = %+v, want %s", listed.Threads, added.ID) - } -} diff --git a/cmd/diffs/output.go b/cmd/diffs/output.go deleted file mode 100644 index 445ac36..0000000 --- a/cmd/diffs/output.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "fmt" - "io" - "os" - "sync" - "time" - - "github.com/imfing/diffs-cli/internal/server" - "golang.org/x/term" -) - -const reloadDebounce = 500 * time.Millisecond - -type startupInfo struct { - URL string - Target string - CWD string - Watching bool - Elapsed time.Duration -} - -type terminalColors struct { - reset string - bold string - dim string - green string - cyan string - yellow string - red string - magenta string -} - -type quietError struct { - err error -} - -func (e quietError) Error() string { - return e.err.Error() -} - -func (e quietError) Unwrap() error { - return e.err -} - -func colorEnabled(w io.Writer) bool { - if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { - return false - } - f, ok := w.(*os.File) - if !ok { - return false - } - return term.IsTerminal(int(f.Fd())) -} - -func colors(enabled bool) terminalColors { - if !enabled { - return terminalColors{} - } - return terminalColors{ - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - green: "\x1b[32m", - cyan: "\x1b[36m", - yellow: "\x1b[33m", - red: "\x1b[31m", - magenta: "\x1b[35m", - } -} - -func printStartup(w io.Writer, info startupInfo, color bool) { - c := colors(color) - _, _ = fmt.Fprintln(w) - printLogLine(w, c, "diffs", fmt.Sprintf("ready in %s", formatReadyDuration(info.Elapsed))) - printLogLine(w, c, "serve", colorize(info.URL, c.cyan, c.reset)) - printLogLine(w, c, "target", info.Target) - if info.Watching { - printLogLine(w, c, "watch", info.CWD) - } - printLogLine(w, c, "stop", colorize("Ctrl+C", c.dim, c.reset)) - _, _ = fmt.Fprintln(w) -} - -func printPortFallback(w io.Writer, requested, actual string, color bool) { - c := colors(color) - _, _ = fmt.Fprintln(w) - printLogLineColor(w, c, "warn", fmt.Sprintf("%s in use; using %s", requested, actual), c.yellow) -} - -func printReload(w io.Writer, _ time.Time, files []server.ChangedFile, color bool) { - c := colors(color) - label, message := reloadLine(files, c, color) - printLogLineColor(w, c, label, message, reloadLabelColor(label, c)) -} - -func printLocalGitHelp(w io.Writer, dir string, color bool) { - c := colors(color) - _, _ = fmt.Fprintln(w) - printLogLine(w, c, "error", fmt.Sprintf("not a git repository: %s", dir)) - printLogLine(w, c, "hint", "run from a git repository") - printLogLine(w, c, "hint", "or pass --dir /path/to/repo") - printLogLine(w, c, "hint", "or use diffs pr /org/repo/pull/123") - _, _ = fmt.Fprintln(w) -} - -func reloadLine(files []server.ChangedFile, c terminalColors, color bool) (string, string) { - if len(files) == 0 { - return "change", "local changes" - } - - action := files[0].Action - label := string(action) - if action == "" { - label = "change" - } - path := files[0].Path - if color { - path = c.cyan + path + c.reset - } - if len(files) == 1 { - return label, path - } - return label, fmt.Sprintf("%s (+%d more)", path, len(files)-1) -} - -func reloadLabelColor(label string, c terminalColors) string { - switch label { - case string(server.ChangeAdded): - return c.green - case string(server.ChangeModified): - return c.yellow - case string(server.ChangeDeleted): - return c.red - case string(server.ChangeRenamed): - return c.magenta - default: - return c.green - } -} - -func newReloadLogger(w io.Writer, color bool) func(time.Time, []server.ChangedFile) { - var mu sync.Mutex - var last time.Time - return func(now time.Time, files []server.ChangedFile) { - mu.Lock() - defer mu.Unlock() - if !last.IsZero() && now.Sub(last) < reloadDebounce { - return - } - last = now - printReload(w, now, files, color) - } -} - -func formatReadyDuration(d time.Duration) string { - ms := d.Round(time.Millisecond).Milliseconds() - if ms < 1 { - ms = 1 - } - return fmt.Sprintf("%d ms", ms) -} - -func printLogLineColor(w io.Writer, c terminalColors, label string, message string, color string) { - if color == "" { - color = c.green - } - _, _ = fmt.Fprintf(w, " %s%-8s%s %s\n", color, label, c.reset, message) -} - -func printLogLine(w io.Writer, c terminalColors, label string, message string) { - printLogLineColor(w, c, label, message, "") -} - -func colorize(text, color, reset string) string { - if color == "" { - return text - } - return color + text + reset -} diff --git a/cmd/diffs/root.go b/cmd/diffs/root.go deleted file mode 100644 index 7495321..0000000 --- a/cmd/diffs/root.go +++ /dev/null @@ -1,94 +0,0 @@ -package main - -import ( - "os" - "strings" - "time" - - "github.com/imfing/diffs-cli/internal/server" - "github.com/spf13/cobra" -) - -const ( - defaultHost = "127.0.0.1" - defaultPort = 3433 - defaultDir = "." -) - -type cliOptions struct { - host string - port int - ghHost string - dir string - noOpen bool -} - -func newRootCommand(started time.Time) *cobra.Command { - opts := &cliOptions{ - host: defaultHost, - port: defaultPort, - ghHost: defaultGithubHost(), - dir: defaultDir, - } - root := &cobra.Command{ - Use: "diffs [flags]", - Short: "Review local diffs and GitHub pull requests in a browser", - SilenceErrors: true, - SilenceUsage: true, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - return runServerTarget(cmd, opts, "/local", started) - }, - } - root.PersistentFlags().StringVar(&opts.dir, "dir", opts.dir, "repository directory for local diff and comments") - addServeFlags(root, opts, false) - root.AddCommand( - newPRCommand(opts, started), - newBranchCommand(opts, started), - newCommentsCommand(opts), - newVersionCommand(), - ) - return root -} - -func newPRCommand(opts *cliOptions, started time.Time) *cobra.Command { - cmd := &cobra.Command{ - Use: "pr [number|github-pr-url|/org/repo/pull/123]", - Short: "Review a GitHub pull request", - Long: "Review a GitHub pull request. With no argument, resolves the PR associated with the current branch via `gh pr view`.", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - target, err := resolvePRTargetFromArgs(args, opts.dir) - if err != nil { - return err - } - return runServerTarget(cmd, opts.withResolvedGitHubHost(cmd, target.Host), target.Path, started) - }, - } - addServeFlags(cmd, opts, true) - return cmd -} - -func addServeFlags(cmd *cobra.Command, opts *cliOptions, includeGHHost bool) { - cmd.Flags().StringVar(&opts.host, "host", opts.host, "host to serve the review UI on") - cmd.Flags().IntVar(&opts.port, "port", opts.port, "port to serve the review UI on") - if includeGHHost { - cmd.Flags().StringVar(&opts.ghHost, "gh-host", opts.ghHost, "GitHub host used by gh api") - } - cmd.Flags().BoolVar(&opts.noOpen, "no-open", false, "do not open the browser automatically") -} - -func defaultGithubHost() string { - if host := strings.TrimSpace(os.Getenv("GH_HOST")); host != "" { - return host - } - return server.DefaultGitHubHost -} - -func (opts *cliOptions) withResolvedGitHubHost(cmd *cobra.Command, targetHost string) *cliOptions { - next := *opts - if strings.TrimSpace(targetHost) != "" && !cmd.Flags().Changed("gh-host") { - next.ghHost = targetHost - } - return &next -} diff --git a/cmd/diffs/serve.go b/cmd/diffs/serve.go deleted file mode 100644 index 3dbd6d6..0000000 --- a/cmd/diffs/serve.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "path/filepath" - "strings" - "time" - - "github.com/imfing/diffs-cli/internal/appconfig" - "github.com/imfing/diffs-cli/internal/server" - "github.com/spf13/cobra" -) - -func runServerTarget(cmd *cobra.Command, opts *cliOptions, targetPath string, started time.Time) error { - if started.IsZero() { - started = time.Now() - } - out := cmd.OutOrStdout() - errOut := cmd.ErrOrStderr() - - displayCWD, err := filepath.Abs(opts.dir) - if err != nil { - return err - } - localGitTarget := isLocalGitTarget(targetPath) - if localGitTarget { - root, err := gitRoot(displayCWD) - if err != nil { - printLocalGitHelp(errOut, displayCWD, colorEnabled(errOut)) - _ = cmd.Help() - return quietError{err: err} - } - displayCWD = root - opts.dir = root - } - appCfg, err := appconfig.LoadDefault() - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - cfg := server.Config{ - CWD: opts.dir, - GitHubHost: opts.ghHost, - UI: appCfg.UI, - Watch: localGitTarget, - } - if localGitTarget { - reload := newReloadLogger(out, colorEnabled(out)) - cfg.OnChange = func(files []server.ChangedFile) { - reload(time.Now(), files) - } - } - handler, err := server.New(cfg) - if err != nil { - return err - } - - srv := &http.Server{ - Handler: handler, - ReadHeaderTimeout: 5 * time.Second, - } - listenAddr, err := listenAddrFromOptions(opts.host, opts.port) - if err != nil { - return err - } - ln, fallback, err := listenWithPortFallback(listenAddr) - if err != nil { - return err - } - url := browserURL(ln.Addr(), targetPath) - if fallback != nil { - printPortFallback(out, fallback.Requested, fallback.Actual, colorEnabled(out)) - } - printStartup(out, startupInfo{ - URL: url, - Target: targetLabel(targetPath, displayCWD), - CWD: displayCWD, - Watching: localGitTarget, - Elapsed: time.Since(started), - }, colorEnabled(out)) - - if !opts.noOpen { - if err := openBrowser(url); err != nil { - _, _ = fmt.Fprintf(errOut, "warning: could not open browser: %v\n", err) - } - } - - if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { - return err - } - return nil -} - -func isLocalGitTarget(targetPath string) bool { - return targetPath == "/local" || targetPath == "/branch" || strings.HasPrefix(targetPath, "/branch?") -} diff --git a/cmd/diffs/target.go b/cmd/diffs/target.go deleted file mode 100644 index f3e09ac..0000000 --- a/cmd/diffs/target.go +++ /dev/null @@ -1,217 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "syscall" -) - -type listenFallback struct { - Requested string - Actual string -} - -type prTarget struct { - Path string - Host string -} - -func listenAddrFromOptions(host string, port int) (string, error) { - if port < 0 || port > 65535 { - return "", fmt.Errorf("port must be between 0 and 65535") - } - host = strings.TrimSpace(host) - if host == "" { - host = "127.0.0.1" - } - return normalizeListenAddr(net.JoinHostPort(host, strconv.Itoa(port))), nil -} - -func listenWithPortFallback(addr string) (net.Listener, *listenFallback, error) { - ln, err := net.Listen("tcp", addr) - if err == nil { - return ln, nil, nil - } - if !isAddrInUse(err) { - return nil, nil, err - } - fallbackAddr, ok := randomPortAddr(addr) - if !ok { - return nil, nil, err - } - ln, fallbackErr := net.Listen("tcp", fallbackAddr) - if fallbackErr != nil { - return nil, nil, fmt.Errorf("%w; fallback to a random port failed: %v", err, fallbackErr) - } - return ln, &listenFallback{Requested: addr, Actual: ln.Addr().String()}, nil -} - -func randomPortAddr(addr string) (string, bool) { - host, port, err := net.SplitHostPort(addr) - if err != nil || port == "0" { - return "", false - } - return net.JoinHostPort(host, "0"), true -} - -func isAddrInUse(err error) bool { - if errors.Is(err, syscall.EADDRINUSE) { - return true - } - message := strings.ToLower(err.Error()) - return strings.Contains(message, "address already in use") || - strings.Contains(message, "only one usage of each socket address") -} - -func normalizeListenAddr(addr string) string { - host, port, err := net.SplitHostPort(addr) - if err != nil { - return addr - } - if host == "localhost" { - return net.JoinHostPort("127.0.0.1", port) - } - return addr -} - -func browserURL(addr net.Addr, targetPath string) string { - host, port, err := net.SplitHostPort(addr.String()) - if err != nil { - return "http://" + addr.String() + targetPath - } - if host == "" || host == "::" || host == "0.0.0.0" { - host = "127.0.0.1" - } - return "http://" + net.JoinHostPort(host, port) + targetPath -} - -func prTargetFromArgs(args []string) (prTarget, error) { - if len(args) != 1 || strings.TrimSpace(args[0]) == "" { - return prTarget{}, fmt.Errorf("expected one GitHub PR target") - } - target := strings.TrimSpace(args[0]) - host := "" - lowerTarget := strings.ToLower(target) - if strings.HasPrefix(lowerTarget, "http://") || strings.HasPrefix(lowerTarget, "https://") { - req, err := http.NewRequest(http.MethodGet, target, nil) - if err != nil { - return prTarget{}, err - } - host = strings.ToLower(req.URL.Hostname()) - if host == "" { - return prTarget{}, fmt.Errorf("target URL must include a host") - } - target = req.URL.Path - } - if !strings.HasPrefix(target, "/") { - target = "/" + target - } - parts := strings.Split(strings.Trim(target, "/"), "/") - if len(parts) >= 4 && parts[2] == "pull" && parts[3] != "" { - if len(parts) == 4 || isPullRequestSubpage(parts[4:]) { - return prTarget{Path: "/" + strings.Join(parts[:4], "/"), Host: host}, nil - } - } - return prTarget{}, fmt.Errorf("target must be a GitHub PR URL or /org/repo/pull/123") -} - -func resolvePRTargetFromArgs(args []string, dir string) (prTarget, error) { - if len(args) == 0 { - url, err := currentBranchPRURL(dir) - if err != nil { - return prTarget{}, err - } - return prTargetFromArgs([]string{url}) - } - target, ok := prNumberFromArgs(args) - if !ok { - return prTargetFromArgs(args) - } - - remote, err := gitRemoteURL(dir, "origin") - if err != nil { - return prTarget{}, fmt.Errorf("resolve current repository for PR #%s: %w", target, err) - } - repo, err := repoFromRemoteURL(remote) - if err != nil { - return prTarget{}, fmt.Errorf("resolve current repository for PR #%s: %w", target, err) - } - return prTarget{ - Path: fmt.Sprintf("/%s/%s/pull/%s", repo.Owner, repo.Name, target), - Host: repo.Host, - }, nil -} - -func prNumberFromArgs(args []string) (string, bool) { - if len(args) != 1 { - return "", false - } - target := strings.TrimSpace(args[0]) - n, err := strconv.Atoi(target) - if err != nil || n <= 0 { - return "", false - } - return target, true -} - -type remoteRepo struct { - Host string - Owner string - Name string -} - -func repoFromRemoteURL(remote string) (remoteRepo, error) { - remote = strings.TrimSpace(remote) - if remote == "" { - return remoteRepo{}, fmt.Errorf("origin remote URL is empty") - } - var host, path string - if strings.Contains(remote, "://") { - u, err := url.Parse(remote) - if err != nil { - return remoteRepo{}, err - } - host = u.Hostname() - if host == "" { - return remoteRepo{}, fmt.Errorf("origin remote URL must include a host") - } - path = u.Path - } else { - userHost, scpPath, ok := strings.Cut(remote, ":") - if !ok || strings.Contains(userHost, "/") { - return remoteRepo{}, fmt.Errorf("origin remote URL must be an absolute URL or SCP-style remote") - } - host = userHost - if _, after, ok := strings.Cut(userHost, "@"); ok { - host = after - } - path = scpPath - } - host = strings.ToLower(host) - parts := strings.Split(strings.Trim(path, "/"), "/") - if len(parts) < 2 { - return remoteRepo{}, fmt.Errorf("origin remote URL must include owner and repository") - } - name := strings.TrimSuffix(parts[1], ".git") - if parts[0] == "" || name == "" { - return remoteRepo{}, fmt.Errorf("origin remote URL must include owner and repository") - } - return remoteRepo{Host: host, Owner: parts[0], Name: name}, nil -} - -func isPullRequestSubpage(parts []string) bool { - if len(parts) != 1 { - return false - } - switch parts[0] { - case "checks", "commits", "files", "reviews": - return true - default: - return false - } -} diff --git a/cmd/diffs/version.go b/cmd/diffs/version.go deleted file mode 100644 index 4b5625d..0000000 --- a/cmd/diffs/version.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var version = "dev" - -func newVersionCommand() *cobra.Command { - return &cobra.Command{ - Use: "version", - Short: "Print the diffs version", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - _, err := fmt.Fprintln(cmd.OutOrStdout(), version) - return err - }, - } -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 965d1d3..0000000 --- a/go.mod +++ /dev/null @@ -1,224 +0,0 @@ -module github.com/imfing/diffs-cli - -go 1.26 - -require ( - github.com/fsnotify/fsnotify v1.10.1 - github.com/pelletier/go-toml/v2 v2.3.1 - github.com/spf13/cobra v1.10.2 - golang.org/x/term v0.43.0 -) - -require ( - 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect - 4d63.com/gochecknoglobals v0.2.2 // indirect - charm.land/lipgloss/v2 v2.0.3 // indirect - codeberg.org/chavacava/garif v0.2.0 // indirect - codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect - dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect - dev.gaijin.team/go/golib v0.6.0 // indirect - github.com/4meepo/tagalign v1.4.3 // indirect - github.com/Abirdcfly/dupword v0.1.7 // indirect - github.com/AdminBenni/iota-mixing v1.0.0 // indirect - github.com/AlwxSin/noinlineerr v1.0.5 // indirect - github.com/Antonboom/errname v1.1.1 // indirect - github.com/Antonboom/nilnil v1.1.1 // indirect - github.com/Antonboom/testifylint v1.6.4 // indirect - github.com/BurntSushi/toml v1.6.0 // indirect - github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect - github.com/Djarvur/go-err113 v0.1.1 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/MirrexOne/unqueryvet v1.5.4 // indirect - github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect - github.com/alecthomas/chroma/v2 v2.24.1 // indirect - github.com/alecthomas/go-check-sumtype v0.3.1 // indirect - github.com/alexkohler/nakedret/v2 v2.0.6 // indirect - github.com/alexkohler/prealloc v1.1.0 // indirect - github.com/alfatraining/structtag v1.0.0 // indirect - github.com/alingse/asasalint v0.0.11 // indirect - github.com/alingse/nilnesserr v0.2.0 // indirect - github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect - github.com/ashanbrown/makezero/v2 v2.2.1 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/bkielbasa/cyclop v1.2.3 // indirect - github.com/blizzy78/varnamelen v0.8.0 // indirect - github.com/bombsimon/wsl/v4 v4.7.0 // indirect - github.com/bombsimon/wsl/v5 v5.8.0 // indirect - github.com/breml/bidichk v0.3.3 // indirect - github.com/breml/errchkjson v0.4.1 // indirect - github.com/butuzov/ireturn v0.4.1 // indirect - github.com/butuzov/mirror v1.3.0 // indirect - github.com/catenacyber/perfsprint v0.10.1 // indirect - github.com/ccojocar/zxcvbn-go v1.0.4 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charithe/durationcheck v0.0.11 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/ckaznocha/intrange v0.3.1 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/curioswitch/go-reassign v0.3.0 // indirect - github.com/daixiang0/gci v0.13.7 // indirect - github.com/dave/dst v0.27.3 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/denis-tingaikin/go-header v0.5.0 // indirect - github.com/dlclark/regexp2 v1.12.0 // indirect - github.com/ettle/strcase v0.2.0 // indirect - github.com/fatih/color v1.19.0 // indirect - github.com/fatih/structtag v1.2.0 // indirect - github.com/firefart/nonamedreturns v1.0.6 // indirect - github.com/fzipp/gocyclo v0.6.0 // indirect - github.com/ghostiam/protogetter v0.3.20 // indirect - github.com/go-critic/go-critic v0.14.3 // indirect - github.com/go-toolsmith/astcast v1.1.0 // indirect - github.com/go-toolsmith/astcopy v1.1.0 // indirect - github.com/go-toolsmith/astequal v1.2.0 // indirect - github.com/go-toolsmith/astfmt v1.1.0 // indirect - github.com/go-toolsmith/astp v1.1.0 // indirect - github.com/go-toolsmith/strparse v1.1.0 // indirect - github.com/go-toolsmith/typep v1.1.0 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect - github.com/gobwas/glob v0.2.3 // indirect - github.com/godoc-lint/godoc-lint v0.11.2 // indirect - github.com/gofrs/flock v0.13.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/golangci/asciicheck v0.5.0 // indirect - github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect - github.com/golangci/go-printf-func-name v0.1.1 // indirect - github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect - github.com/golangci/golangci-lint/v2 v2.12.0 // indirect - github.com/golangci/golines v0.15.0 // indirect - github.com/golangci/misspell v0.8.0 // indirect - github.com/golangci/plugin-module-register v0.1.2 // indirect - github.com/golangci/revgrep v0.8.0 // indirect - github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect - github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect - github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/gordonklaus/ineffassign v0.2.0 // indirect - github.com/gostaticanalysis/analysisutil v0.7.1 // indirect - github.com/gostaticanalysis/comment v1.5.0 // indirect - github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect - github.com/gostaticanalysis/nilerr v0.1.2 // indirect - github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect - github.com/hashicorp/go-version v1.9.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jgautheron/goconst v1.10.0 // indirect - github.com/jjti/go-spancheck v0.6.5 // indirect - github.com/julz/importas v0.2.0 // indirect - github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect - github.com/kisielk/errcheck v1.10.0 // indirect - github.com/kkHAIKE/contextcheck v1.1.6 // indirect - github.com/kulti/thelper v0.7.1 // indirect - github.com/kunwardeep/paralleltest v1.0.15 // indirect - github.com/lasiar/canonicalheader v1.1.2 // indirect - github.com/ldez/exptostd v0.4.5 // indirect - github.com/ldez/gomoddirectives v0.8.0 // indirect - github.com/ldez/grignotin v0.10.1 // indirect - github.com/ldez/structtags v0.6.1 // indirect - github.com/ldez/tagliatelle v0.7.2 // indirect - github.com/ldez/usetesting v0.5.0 // indirect - github.com/leonklingele/grouper v1.1.2 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/macabu/inamedparam v0.2.0 // indirect - github.com/magiconair/properties v1.8.6 // indirect - github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect - github.com/manuelarte/funcorder v0.6.0 // indirect - github.com/maratori/testableexamples v1.0.1 // indirect - github.com/maratori/testpackage v1.1.2 // indirect - github.com/matoous/godox v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect - github.com/mgechev/revive v1.15.0 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/moricho/tparallel v0.3.2 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/nakabonne/nestif v0.3.1 // indirect - github.com/nishanths/exhaustive v0.12.0 // indirect - github.com/nishanths/predeclared v0.2.2 // indirect - github.com/nunnatsa/ginkgolinter v0.23.0 // indirect - github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.12.1 // indirect - github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.32.1 // indirect - github.com/prometheus/procfs v0.7.3 // indirect - github.com/quasilyte/go-ruleguard v0.4.5 // indirect - github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect - github.com/quasilyte/gogrep v0.5.0 // indirect - github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect - github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect - github.com/raeperd/recvcheck v0.2.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/ryancurrah/gomodguard v1.4.1 // indirect - github.com/ryancurrah/gomodguard/v2 v2.1.0 // indirect - github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect - github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect - github.com/sashamelentyev/interfacebloat v1.1.0 // indirect - github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect - github.com/securego/gosec/v2 v2.26.1 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect - github.com/sivchari/containedctx v1.0.3 // indirect - github.com/sonatard/noctx v0.5.1 // indirect - github.com/sourcegraph/go-diff v0.8.0 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.5.0 // indirect - github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.12.0 // indirect - github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect - github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect - github.com/subosito/gotenv v1.4.1 // indirect - github.com/tetafro/godot v1.5.6 // indirect - github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect - github.com/timonwong/loggercheck v0.11.0 // indirect - github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect - github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect - github.com/ultraware/funlen v0.2.0 // indirect - github.com/ultraware/whitespace v0.2.0 // indirect - github.com/uudashr/gocognit v1.2.1 // indirect - github.com/uudashr/iface v1.4.1 // indirect - github.com/xen0n/gosmopolitan v1.3.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yagipy/maintidx v1.0.0 // indirect - github.com/yeya24/promlinter v0.3.0 // indirect - github.com/ykadowak/zerologlint v0.1.5 // indirect - gitlab.com/bosi/decorder v0.4.2 // indirect - go-simpler.org/musttag v0.14.0 // indirect - go-simpler.org/sloglint v0.12.0 // indirect - go.augendre.info/arangolint v0.4.0 // indirect - go.augendre.info/fatcontext v0.9.0 // indirect - go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.44.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - honnef.co/go/tools v0.7.0 // indirect - mvdan.cc/gofumpt v0.9.2 // indirect - mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect -) - -tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint diff --git a/go.sum b/go.sum deleted file mode 100644 index 24dd27a..0000000 --- a/go.sum +++ /dev/null @@ -1,977 +0,0 @@ -4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A= -4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= -4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= -4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= -charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= -charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= -codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= -codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= -codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8= -dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= -dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= -dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= -dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= -github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= -github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ= -github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4= -github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo= -github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= -github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= -github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= -github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q= -github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ= -github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ= -github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II= -github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= -github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/ClickHouse/clickhouse-go-linter v1.2.0 h1:zbm174up3hTKjp0wKZVnTzRiG7tSF5XZF0FJG/MuCBI= -github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4= -github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= -github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= -github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ= -github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= -github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= -github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= -github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= -github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= -github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= -github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= -github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= -github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M= -github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig= -github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= -github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= -github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= -github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= -github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= -github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= -github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI= -github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM= -github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM= -github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= -github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= -github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= -github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= -github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= -github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= -github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs= -github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o= -github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= -github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= -github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= -github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= -github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I= -github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4= -github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= -github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= -github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= -github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= -github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= -github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= -github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= -github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= -github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= -github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= -github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= -github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= -github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= -github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= -github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= -github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= -github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= -github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= -github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= -github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= -github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= -github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= -github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E= -github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= -github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= -github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= -github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= -github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= -github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= -github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= -github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= -github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= -github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= -github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= -github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= -github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= -github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= -github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= -github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= -github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= -github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= -github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= -github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= -github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= -github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= -github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= -github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= -github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= -github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= -github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= -github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= -github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= -github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= -github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= -github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A= -github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= -github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= -github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= -github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= -github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= -github.com/golangci/golangci-lint/v2 v2.12.0 h1:fd61aD+XaAl+APBGWcbxzi+K0tb33JogvMG3ypJLtH8= -github.com/golangci/golangci-lint/v2 v2.12.0/go.mod h1:e/wBh0xvA13ag/OWByUmvjc9oYPtcKGpXycldJbc7t0= -github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0= -github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10= -github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg= -github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= -github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= -github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= -github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= -github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= -github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba h1:lqtcnSMDuuJdu/LrKWi5RJzpSNLOJXYe/nzQutTI5kg= -github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sCBNcpRmhJCtbFGz49+IM3ETTFf7QdJ30AeYCd43NKk= -github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= -github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= -github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= -github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= -github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= -github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= -github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= -github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= -github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= -github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= -github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= -github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= -github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU= -github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= -github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= -github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= -github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= -github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= -github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= -github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= -github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= -github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jgautheron/goconst v1.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw= -github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= -github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= -github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= -github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= -github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= -github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= -github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw= -github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= -github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= -github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= -github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w= -github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= -github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= -github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= -github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= -github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= -github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk= -github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q= -github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= -github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= -github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk= -github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY= -github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk= -github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI= -github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= -github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= -github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= -github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= -github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= -github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= -github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= -github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= -github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0= -github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g= -github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8= -github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= -github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= -github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= -github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= -github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= -github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q= -github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= -github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= -github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= -github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= -github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= -github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= -github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= -github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= -github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= -github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= -github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= -github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= -github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= -github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= -github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= -github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= -github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= -github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= -github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= -github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= -github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= -github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= -github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= -github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= -github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= -github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= -github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= -github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= -github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= -github.com/ryancurrah/gomodguard/v2 v2.1.0 h1:iIIARHe7Fsp10LY5utfMmYA++hkVuKsMFGDzxnVcijU= -github.com/ryancurrah/gomodguard/v2 v2.1.0/go.mod h1:ryDqr6as4otkNbUp/U0m7zAsxGpwcJ9NtL6mvy9Zzdw= -github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg= -github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU= -github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= -github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= -github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= -github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= -github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= -github.com/securego/gosec/v2 v2.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4= -github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE= -github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= -github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= -github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs= -github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= -github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY= -github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= -github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= -github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= -github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= -github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= -github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= -github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= -github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= -github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= -github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= -github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= -github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= -github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA= -github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= -github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0= -github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M= -github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= -github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= -github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is= -github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= -github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= -github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= -github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= -github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= -github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= -github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= -github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4= -github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q= -github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU= -github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= -github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= -github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= -github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= -github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= -github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= -github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= -github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= -gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= -go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= -go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= -go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= -go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= -go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4= -go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I= -go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50= -go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= -go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE= -go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= -golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= -golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= -golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= -golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= -honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= -mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= -mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= -mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= -mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go deleted file mode 100644 index 7432e78..0000000 --- a/internal/appconfig/config.go +++ /dev/null @@ -1,120 +0,0 @@ -package appconfig - -import ( - "errors" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/pelletier/go-toml/v2" -) - -const ( - ColorSchemeDark = "dark" - ColorSchemeLight = "light" - ColorSchemeSystem = "system" - - DiffStyleSplit = "split" - DiffStyleUnified = "unified" - - DiffThemePierre = "pierre" - DiffThemeGitHub = "github" - DiffThemeDarkPlus = "dark-plus" - DiffThemeLightPlus = "light-plus" - DiffThemeOneDarkPro = "one-dark-pro" - DiffThemeOneLight = "one-light" - DiffThemeMonokai = "monokai" - DiffThemeNightOwl = "night-owl" - DiffThemeTokyoNight = "tokyo-night" -) - -var ( - colorSchemes = []string{ - ColorSchemeDark, - ColorSchemeLight, - ColorSchemeSystem, - } - diffStyles = []string{ - DiffStyleSplit, - DiffStyleUnified, - } - diffThemes = []string{ - DiffThemePierre, - DiffThemeGitHub, - DiffThemeDarkPlus, - DiffThemeLightPlus, - DiffThemeOneDarkPro, - DiffThemeOneLight, - DiffThemeMonokai, - DiffThemeNightOwl, - DiffThemeTokyoNight, - } -) - -type Config struct { - UI UIConfig `toml:"ui"` -} - -type UIConfig struct { - ColorScheme string `toml:"color_scheme"` - DiffTheme string `toml:"diff_theme"` - DiffStyle string `toml:"diff_style"` - UIFontFamily string `toml:"ui_font_family"` - CodeFontFamily string `toml:"code_font_family"` - WordWrap *bool `toml:"word_wrap"` - LineNumbers *bool `toml:"line_numbers"` - LineBackgrounds *bool `toml:"line_backgrounds"` -} - -func DefaultPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".config", "diffs", "config.toml"), nil -} - -func LoadDefault() (Config, error) { - path, err := DefaultPath() - if err != nil { - return Config{}, err - } - return Load(path) -} - -func Load(path string) (Config, error) { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return Config{}, nil - } - if err != nil { - return Config{}, err - } - var cfg Config - if err := toml.Unmarshal(data, &cfg); err != nil { - return Config{}, err - } - return cfg, nil -} - -func NormalizeUIConfig(ui UIConfig) UIConfig { - ui.ColorScheme = strings.TrimSpace(ui.ColorScheme) - ui.DiffTheme = strings.TrimSpace(ui.DiffTheme) - ui.DiffStyle = strings.TrimSpace(ui.DiffStyle) - ui.UIFontFamily = strings.TrimSpace(ui.UIFontFamily) - ui.CodeFontFamily = strings.TrimSpace(ui.CodeFontFamily) - return ui -} - -func IsColorScheme(s string) bool { - return slices.Contains(colorSchemes, s) -} - -func IsDiffTheme(s string) bool { - return slices.Contains(diffThemes, s) -} - -func IsDiffStyle(s string) bool { - return slices.Contains(diffStyles, s) -} diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go deleted file mode 100644 index 0755c9e..0000000 --- a/internal/appconfig/config_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package appconfig - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadMissingConfigReturnsEmptyConfig(t *testing.T) { - cfg, err := Load(filepath.Join(t.TempDir(), "missing.toml")) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.UI.ColorScheme != "" || cfg.UI.WordWrap != nil { - t.Fatalf("Load() = %+v, want empty config", cfg) - } -} - -func TestLoadConfig(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.toml") - if err := os.WriteFile(path, []byte(` -[ui] -color_scheme = "dark" -diff_theme = "github" -diff_style = "unified" -ui_font_family = '"Inter Variable", system-ui, sans-serif' -code_font_family = '"JetBrains Mono", ui-monospace, monospace' -word_wrap = true -line_numbers = false -line_backgrounds = true -`), 0o644); err != nil { - t.Fatal(err) - } - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.UI.ColorScheme != "dark" || cfg.UI.DiffTheme != "github" || cfg.UI.DiffStyle != "unified" { - t.Fatalf("unexpected string settings: %+v", cfg.UI) - } - if cfg.UI.UIFontFamily != `"Inter Variable", system-ui, sans-serif` { - t.Fatalf("ui_font_family = %q", cfg.UI.UIFontFamily) - } - if cfg.UI.CodeFontFamily != `"JetBrains Mono", ui-monospace, monospace` { - t.Fatalf("code_font_family = %q", cfg.UI.CodeFontFamily) - } - if cfg.UI.WordWrap == nil || !*cfg.UI.WordWrap { - t.Fatalf("word_wrap = %v, want true", cfg.UI.WordWrap) - } - if cfg.UI.LineNumbers == nil || *cfg.UI.LineNumbers { - t.Fatalf("line_numbers = %v, want false", cfg.UI.LineNumbers) - } - if cfg.UI.LineBackgrounds == nil || !*cfg.UI.LineBackgrounds { - t.Fatalf("line_backgrounds = %v, want true", cfg.UI.LineBackgrounds) - } -} - -func TestLoadInvalidConfigReturnsError(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.toml") - if err := os.WriteFile(path, []byte("[ui\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := Load(path); err == nil { - t.Fatal("Load() succeeded, want error") - } -} - -func TestNormalizeUIConfigTrimsStringSettings(t *testing.T) { - got := NormalizeUIConfig(UIConfig{ - ColorScheme: " dark ", - DiffTheme: " github ", - DiffStyle: " unified ", - UIFontFamily: " ui-sans-serif ", - CodeFontFamily: " ui-monospace ", - }) - if got.ColorScheme != ColorSchemeDark || got.DiffTheme != DiffThemeGitHub || got.DiffStyle != DiffStyleUnified { - t.Fatalf("NormalizeUIConfig() = %+v", got) - } - if got.UIFontFamily != "ui-sans-serif" || got.CodeFontFamily != "ui-monospace" { - t.Fatalf("NormalizeUIConfig() font families = %+v", got) - } -} - -func TestUIOptionValidation(t *testing.T) { - if !IsColorScheme(ColorSchemeSystem) || IsColorScheme("auto") { - t.Fatal("unexpected color scheme validation") - } - if !IsDiffTheme(DiffThemePierre) || IsDiffTheme("missing") { - t.Fatal("unexpected diff theme validation") - } - if !IsDiffStyle(DiffStyleSplit) || IsDiffStyle("side-by-side") { - t.Fatal("unexpected diff style validation") - } -} diff --git a/internal/comments/store.go b/internal/comments/store.go deleted file mode 100644 index 2d2444f..0000000 --- a/internal/comments/store.go +++ /dev/null @@ -1,402 +0,0 @@ -package comments - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - pathpkg "path" - "path/filepath" - "strings" - "sync" - "time" - - gitcmd "github.com/imfing/diffs-cli/internal/git" -) - -const ( - DefaultAuthor = "local" - DefaultSide = "additions" -) - -var ErrNotFound = errors.New("comment thread not found") - -type Store struct { - root string - path string - now func() time.Time - mu sync.Mutex -} - -type File struct { - Version int `json:"version"` - Repo string `json:"repo"` - Threads []Thread `json:"threads"` -} - -type Thread struct { - ID string `json:"id"` - Provider string `json:"provider"` - Branch string `json:"branch"` - Path string `json:"path"` - Side string `json:"side"` - Line int `json:"line"` - EndSide string `json:"endSide,omitempty"` - EndLine int `json:"endLine,omitempty"` - Status string `json:"status"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Comments []Comment `json:"comments"` - ReplyToID int64 `json:"replyToId,omitempty"` - URL string `json:"url,omitempty"` -} - -type Comment struct { - ID string `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - CreatedAt time.Time `json:"createdAt"` -} - -type AddThreadInput struct { - Path string `json:"path"` - Side string `json:"side"` - Line int `json:"line"` - EndSide string `json:"endSide"` - EndLine int `json:"endLine"` - Body string `json:"body"` - Author string `json:"author"` -} - -type AddReplyInput struct { - Body string `json:"body"` - Author string `json:"author"` -} - -func NewStore(cwd string) (*Store, error) { - ctx, cancel := context.WithTimeout(context.Background(), gitcmd.DefaultTimeout) - defer cancel() - - root, err := gitcmd.Root(ctx, cwd) - if err != nil { - return nil, err - } - return &Store{ - root: root, - path: filepath.Join(root, ".diffs", "comments.json"), - now: time.Now, - }, nil -} - -func (s *Store) Path() string { - return s.path -} - -func (s *Store) Root() string { - return s.root -} - -func (s *Store) Branch(ctx context.Context) string { - if branch := gitcmd.Branch(ctx, s.root); branch != "" { - return branch - } - return "local" -} - -func (s *Store) List(ctx context.Context) ([]Thread, error) { - s.mu.Lock() - defer s.mu.Unlock() - - file, err := s.load() - if err != nil { - return nil, err - } - branch := s.Branch(ctx) - threads := make([]Thread, 0, len(file.Threads)) - for _, thread := range file.Threads { - if thread.Branch == branch { - threads = append(threads, thread) - } - } - return threads, nil -} - -func (s *Store) AddThread(ctx context.Context, input AddThreadInput) (Thread, error) { - path, side, line, endSide, endLine, body, err := CleanThreadInput(input) - if err != nil { - return Thread{}, err - } - author := s.cleanAuthor(ctx, input.Author) - now := s.now().UTC() - - s.mu.Lock() - defer s.mu.Unlock() - - thread := Thread{ - ID: newID("thr"), - Provider: "local", - Branch: s.Branch(ctx), - Path: path, - Side: side, - Line: line, - Status: "open", - CreatedAt: now, - UpdatedAt: now, - Comments: []Comment{{ - ID: newID("cmt"), - Author: author, - Body: body, - CreatedAt: now, - }}, - } - if endLine != line || endSide != side { - thread.EndSide = endSide - thread.EndLine = endLine - } - - file, err := s.load() - if err != nil { - return Thread{}, err - } - file.Threads = append(file.Threads, thread) - if err := s.save(file); err != nil { - return Thread{}, err - } - return thread, nil -} - -func (s *Store) AddReply(ctx context.Context, threadID string, input AddReplyInput) (Thread, error) { - body := strings.TrimSpace(input.Body) - if body == "" { - return Thread{}, errors.New("body is required") - } - author := s.cleanAuthor(ctx, input.Author) - return s.updateThread(ctx, threadID, func(thread *Thread, now time.Time) error { - thread.Comments = append(thread.Comments, Comment{ - ID: newID("cmt"), - Author: author, - Body: body, - CreatedAt: now, - }) - thread.UpdatedAt = now - return nil - }) -} - -func (s *Store) Resolve(ctx context.Context, threadID string) (Thread, error) { - return s.setStatus(ctx, threadID, "resolved") -} - -func (s *Store) Reopen(ctx context.Context, threadID string) (Thread, error) { - return s.setStatus(ctx, threadID, "open") -} - -func (s *Store) Delete(ctx context.Context, threadID string) error { - threadID = strings.TrimSpace(threadID) - if threadID == "" { - return errors.New("thread id is required") - } - - s.mu.Lock() - defer s.mu.Unlock() - - file, err := s.load() - if err != nil { - return err - } - branch := s.Branch(ctx) - for i := range file.Threads { - if file.Threads[i].ID != threadID || file.Threads[i].Branch != branch { - continue - } - file.Threads = append(file.Threads[:i], file.Threads[i+1:]...) - return s.save(file) - } - return ErrNotFound -} - -func (s *Store) setStatus(ctx context.Context, threadID, status string) (Thread, error) { - return s.updateThread(ctx, threadID, func(thread *Thread, now time.Time) error { - thread.Status = status - thread.UpdatedAt = now - return nil - }) -} - -func (s *Store) updateThread(ctx context.Context, threadID string, update func(*Thread, time.Time) error) (Thread, error) { - threadID = strings.TrimSpace(threadID) - if threadID == "" { - return Thread{}, errors.New("thread id is required") - } - - s.mu.Lock() - defer s.mu.Unlock() - - file, err := s.load() - if err != nil { - return Thread{}, err - } - branch := s.Branch(ctx) - for i := range file.Threads { - if file.Threads[i].ID != threadID || file.Threads[i].Branch != branch { - continue - } - now := s.now().UTC() - if err := update(&file.Threads[i], now); err != nil { - return Thread{}, err - } - thread := file.Threads[i] - if err := s.save(file); err != nil { - return Thread{}, err - } - return thread, nil - } - return Thread{}, ErrNotFound -} - -func (s *Store) load() (File, error) { - data, err := os.ReadFile(s.path) - if errors.Is(err, os.ErrNotExist) { - return File{Version: 1, Repo: s.root, Threads: []Thread{}}, nil - } - if err != nil { - return File{}, err - } - if len(strings.TrimSpace(string(data))) == 0 { - return File{Version: 1, Repo: s.root, Threads: []Thread{}}, nil - } - var file File - if err := json.Unmarshal(data, &file); err != nil { - return File{}, err - } - if file.Version == 0 { - file.Version = 1 - } - if file.Repo == "" { - file.Repo = s.root - } - if file.Threads == nil { - file.Threads = []Thread{} - } - return file, nil -} - -func (s *Store) save(file File) error { - file.Version = 1 - file.Repo = s.root - if file.Threads == nil { - file.Threads = []Thread{} - } - dir := filepath.Dir(s.path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(file, "", " ") - if err != nil { - return err - } - data = append(data, '\n') - - tmp, err := os.CreateTemp(dir, ".comments-*.json") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, s.path) -} - -// CleanThreadInput normalizes and validates a thread before it is stored or sent remotely. -func CleanThreadInput(input AddThreadInput) (string, string, int, string, int, string, error) { - path, side, line, endSide, endLine, body := input.Path, input.Side, input.Line, input.EndSide, input.EndLine, input.Body - path = strings.ReplaceAll(strings.TrimSpace(path), "\\", "/") - side = strings.TrimSpace(side) - endSide = strings.TrimSpace(endSide) - body = strings.TrimSpace(body) - if path == "" { - return "", "", 0, "", 0, "", errors.New("path is required") - } - if hasParentPathSegment(path) { - return "", "", 0, "", 0, "", errors.New("path must be relative to the repository") - } - path = pathpkg.Clean(path) - if path == "." { - return "", "", 0, "", 0, "", errors.New("path is required") - } - if !filepath.IsLocal(path) { - return "", "", 0, "", 0, "", errors.New("path must be relative to the repository") - } - if line < 1 { - return "", "", 0, "", 0, "", errors.New("line must be greater than zero") - } - if endLine == 0 { - endLine = line - } - if endLine < 1 { - return "", "", 0, "", 0, "", errors.New("end line must be greater than zero") - } - if endLine < line { - return "", "", 0, "", 0, "", errors.New("end line must be greater than or equal to line") - } - if side == "" { - side = DefaultSide - } - if endSide == "" { - endSide = side - } - if side != "additions" && side != "deletions" { - return "", "", 0, "", 0, "", errors.New("side must be additions or deletions") - } - if endSide != "additions" && endSide != "deletions" { - return "", "", 0, "", 0, "", errors.New("end side must be additions or deletions") - } - if body == "" { - return "", "", 0, "", 0, "", errors.New("body is required") - } - return path, side, line, endSide, endLine, body, nil -} - -func hasParentPathSegment(path string) bool { - for _, part := range strings.Split(path, "/") { - if part == ".." { - return true - } - } - return false -} - -func (s *Store) cleanAuthor(ctx context.Context, author string) string { - author = strings.TrimSpace(author) - if author == "" { - return s.defaultAuthor(ctx) - } - return author -} - -func (s *Store) defaultAuthor(ctx context.Context) string { - name, err := gitcmd.Run(ctx, s.root, "config", "--get", "user.name") - if err == nil { - if author := strings.TrimSpace(string(name)); author != "" { - return author - } - } - return DefaultAuthor -} - -func newID(prefix string) string { - var b [8]byte - if _, err := rand.Read(b[:]); err != nil { - return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) - } - return prefix + "_" + hex.EncodeToString(b[:]) -} diff --git a/internal/comments/store_test.go b/internal/comments/store_test.go deleted file mode 100644 index b7fa3d3..0000000 --- a/internal/comments/store_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package comments - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "testing" - "time" -) - -func TestStoreAddReplyResolveAndReopen(t *testing.T) { - dir := newGitRepo(t) - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - store.now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) } - - thread, err := store.AddThread(context.Background(), AddThreadInput{ - Path: "web/src/App.tsx", - Line: 42, - EndLine: 45, - Side: "additions", - Body: "Check this", - }) - if err != nil { - t.Fatalf("AddThread() error = %v", err) - } - if thread.ID == "" || thread.Provider != "local" || thread.Status != "open" || thread.Branch != "main" { - t.Fatalf("unexpected thread metadata: %+v", thread) - } - if thread.Line != 42 || thread.EndLine != 45 || thread.Side != "additions" || thread.EndSide != "additions" { - t.Fatalf("unexpected thread range: %+v", thread) - } - if len(thread.Comments) != 1 || thread.Comments[0].Body != "Check this" || thread.Comments[0].Author != "Test" { - t.Fatalf("unexpected comments: %+v", thread.Comments) - } - - thread, err = store.AddReply(context.Background(), thread.ID, AddReplyInput{Body: "Reply", Author: "agent"}) - if err != nil { - t.Fatalf("AddReply() error = %v", err) - } - if len(thread.Comments) != 2 || thread.Comments[1].Body != "Reply" || thread.Comments[1].Author != "agent" { - t.Fatalf("reply was not appended: %+v", thread.Comments) - } - - thread, err = store.Resolve(context.Background(), thread.ID) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - if thread.Status != "resolved" { - t.Fatalf("status = %q, want resolved", thread.Status) - } - - thread, err = store.Reopen(context.Background(), thread.ID) - if err != nil { - t.Fatalf("Reopen() error = %v", err) - } - if thread.Status != "open" { - t.Fatalf("status = %q, want open", thread.Status) - } - - if err := store.Delete(context.Background(), thread.ID); err != nil { - t.Fatalf("Delete() error = %v", err) - } - threads, err := store.List(context.Background()) - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(threads) != 0 { - t.Fatalf("threads = %+v, want deleted thread removed", threads) - } -} - -func TestStoreFallsBackToLocalAuthorWithoutGitConfig(t *testing.T) { - t.Setenv("GIT_CONFIG_NOSYSTEM", "1") - t.Setenv("HOME", t.TempDir()) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - thread, err := store.AddThread(context.Background(), AddThreadInput{ - Path: "web/src/App.tsx", - Line: 42, - Body: "Check this", - }) - if err != nil { - t.Fatalf("AddThread() error = %v", err) - } - if len(thread.Comments) != 1 || thread.Comments[0].Author != DefaultAuthor { - t.Fatalf("unexpected comments: %+v", thread.Comments) - } -} - -func TestStoreListsCurrentBranchOnly(t *testing.T) { - dir := newGitRepo(t) - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - if _, err := store.AddThread(context.Background(), AddThreadInput{Path: "a.go", Line: 1, Body: "main"}); err != nil { - t.Fatal(err) - } - - git(t, dir, "checkout", "-b", "feature/comments") - if _, err := store.AddThread(context.Background(), AddThreadInput{Path: "b.go", Line: 1, Body: "feature"}); err != nil { - t.Fatal(err) - } - - threads, err := store.List(context.Background()) - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(threads) != 1 || threads[0].Path != "b.go" { - t.Fatalf("threads = %+v, want only feature branch thread", threads) - } -} - -func TestStoreKeepsConcurrentAdds(t *testing.T) { - dir := newGitRepo(t) - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - - const count = 20 - errs := make(chan error, count) - var wg sync.WaitGroup - for i := range count { - wg.Add(1) - go func(i int) { - defer wg.Done() - _, err := store.AddThread(context.Background(), AddThreadInput{ - Path: fmt.Sprintf("file-%02d.go", i), - Line: 1, - Body: "body", - }) - errs <- err - }(i) - } - wg.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatalf("AddThread() error = %v", err) - } - } - - threads, err := store.List(context.Background()) - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(threads) != count { - t.Fatalf("thread count = %d, want %d", len(threads), count) - } -} - -func TestStoreReturnsNotFoundForOtherBranch(t *testing.T) { - dir := newGitRepo(t) - store, err := NewStore(dir) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - thread, err := store.AddThread(context.Background(), AddThreadInput{Path: "a.go", Line: 1, Body: "main"}) - if err != nil { - t.Fatal(err) - } - - git(t, dir, "checkout", "-b", "feature/comments") - _, err = store.Resolve(context.Background(), thread.ID) - if !errors.Is(err, ErrNotFound) { - t.Fatalf("Resolve() error = %v, want ErrNotFound", err) - } -} - -func TestStoreRejectsInvalidThreadInput(t *testing.T) { - for _, input := range []AddThreadInput{ - {Path: "", Line: 1, Body: "body"}, - {Path: "../outside", Line: 1, Body: "body"}, - {Path: "a/../b", Line: 1, Body: "body"}, - {Path: "a/../../outside", Line: 1, Body: "body"}, - {Path: `a\..\b`, Line: 1, Body: "body"}, - {Path: `a\..\..\outside`, Line: 1, Body: "body"}, - {Path: "a.go", Line: 0, Body: "body"}, - {Path: "a.go", Line: 1, EndLine: -1, Body: "body"}, - {Path: "a.go", Line: 10, EndLine: 1, Body: "body"}, - {Path: "a.go", Line: 1, Side: "right", Body: "body"}, - {Path: "a.go", Line: 1, EndSide: "right", Body: "body"}, - {Path: "a.go", Line: 1, Body: ""}, - } { - t.Run(input.Path, func(t *testing.T) { - if _, _, _, _, _, _, err := CleanThreadInput(input); err == nil { - t.Fatalf("CleanThreadInput(%+v) succeeded, want error", input) - } - }) - } -} - -func newGitRepo(t *testing.T) string { - t.Helper() - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - git(t, dir, "config", "user.email", "test@example.com") - git(t, dir, "config", "user.name", "Test") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("test\n"), 0o644); err != nil { - t.Fatal(err) - } - git(t, dir, "add", "README.md") - git(t, dir, "commit", "-m", "init") - return dir -} - -func git(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) - } -} diff --git a/internal/git/git.go b/internal/git/git.go deleted file mode 100644 index 3396d40..0000000 --- a/internal/git/git.go +++ /dev/null @@ -1,57 +0,0 @@ -package git - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -const DefaultTimeout = 2 * time.Second - -var ErrNotRepository = errors.New("not a git repository") - -func Command(ctx context.Context, dir string, args ...string) *exec.Cmd { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") - return cmd -} - -func Run(ctx context.Context, dir string, args ...string) ([]byte, error) { - return Command(ctx, dir, args...).Output() -} - -func OK(ctx context.Context, dir string, args ...string) bool { - return Command(ctx, dir, args...).Run() == nil -} - -func Root(ctx context.Context, cwd string) (string, error) { - if cwd == "" { - cwd = "." - } - abs, err := filepath.Abs(cwd) - if err != nil { - return "", err - } - root, err := Run(ctx, abs, "rev-parse", "--show-toplevel") - if err != nil { - return "", ErrNotRepository - } - return strings.TrimSpace(string(root)), nil -} - -func Branch(ctx context.Context, dir string) string { - branch, err := Run(ctx, dir, "branch", "--show-current") - if err == nil && strings.TrimSpace(string(branch)) != "" { - return strings.TrimSpace(string(branch)) - } - commit, err := Run(ctx, dir, "rev-parse", "--short", "HEAD") - if err == nil { - return strings.TrimSpace(string(commit)) - } - return "" -} diff --git a/internal/server/github_comments.go b/internal/server/github_comments.go deleted file mode 100644 index c53453b..0000000 --- a/internal/server/github_comments.go +++ /dev/null @@ -1,504 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os/exec" - "strconv" - "strings" - "time" - - "github.com/imfing/diffs-cli/internal/comments" -) - -const githubCommentsTimeout = 30 * time.Second - -var runGH = defaultRunGH - -type githubReviewThreadsResponse struct { - Data struct { - Repository struct { - PullRequest struct { - ReviewThreads struct { - Nodes []githubReviewThread `json:"nodes"` - PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - } `json:"reviewThreads"` - } `json:"pullRequest"` - } `json:"repository"` - } `json:"data"` -} - -type githubReviewThread struct { - ID string `json:"id"` - IsResolved bool `json:"isResolved"` - Path string `json:"path"` - Line int `json:"line"` - DiffSide string `json:"diffSide"` - StartLine int `json:"startLine"` - StartDiffSide string `json:"startDiffSide"` - Comments struct { - Nodes []githubReviewComment `json:"nodes"` - } `json:"comments"` -} - -type githubReviewComment struct { - ID string `json:"id"` - DatabaseID int64 `json:"databaseId"` - Author *ghAuthor `json:"author"` - Body string `json:"body"` - URL string `json:"url"` - CreatedAt time.Time `json:"createdAt"` -} - -type ghAuthor struct { - Login string `json:"login"` -} - -type githubPullResponse struct { - Title string `json:"title"` - State string `json:"state"` - Draft bool `json:"draft"` - Merged bool `json:"merged"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - Changed int `json:"changed_files"` - Commits int `json:"commits"` - User *ghAuthor `json:"user"` - Head struct { - SHA string `json:"sha"` - Ref string `json:"ref"` - Label string `json:"label"` - Repo struct { - FullName string `json:"full_name"` - } `json:"repo"` - } `json:"head"` - Base struct { - Ref string `json:"ref"` - Label string `json:"label"` - Repo struct { - FullName string `json:"full_name"` - } `json:"repo"` - } `json:"base"` -} - -type pullRequestInfoResponse struct { - Title string `json:"title"` - State string `json:"state"` - Draft bool `json:"draft"` - Merged bool `json:"merged"` - Author string `json:"author"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - ChangedFiles int `json:"changedFiles"` - Commits int `json:"commits"` - HeadRef string `json:"headRef"` - HeadLabel string `json:"headLabel"` - HeadRepo string `json:"headRepo"` - BaseRef string `json:"baseRef"` - BaseLabel string `json:"baseLabel"` - BaseRepo string `json:"baseRepo"` -} - -type githubCreatedComment struct { - ID int64 `json:"id"` - NodeID string `json:"node_id"` -} - -func (s *Server) listPullRequestComments(ctx context.Context, org, repo, number string) ([]comments.Thread, error) { - ctx, cancel := context.WithTimeout(ctx, githubCommentsTimeout) - defer cancel() - - var threads []comments.Thread - cursor := "" - for { - args := []string{ - "api", - "graphql", - "--hostname", - s.githubHost, - "-f", - "query=" + reviewThreadsQuery, - "-F", - "owner=" + org, - "-F", - "name=" + repo, - "-F", - "number=" + number, - } - if cursor != "" { - args = append(args, "-F", "cursor="+cursor) - } - out, err := ghOutput(ctx, "gh api graphql", args...) - if err != nil { - return nil, err - } - var response githubReviewThreadsResponse - if err := json.Unmarshal(out, &response); err != nil { - return nil, err - } - page := response.Data.Repository.PullRequest.ReviewThreads - for _, thread := range page.Nodes { - if converted, ok := convertGitHubThread(thread); ok { - threads = append(threads, converted) - } - } - if !page.PageInfo.HasNextPage { - return threads, nil - } - cursor = page.PageInfo.EndCursor - if cursor == "" { - return threads, nil - } - } -} - -func (s *Server) addPullRequestComment(ctx context.Context, org, repo, number string, input comments.AddThreadInput) (comments.Thread, error) { - path, side, line, endSide, endLine, body, err := comments.CleanThreadInput(input) - if err != nil { - return comments.Thread{}, err - } - sha, err := s.pullRequestHeadSHA(ctx, org, repo, number) - if err != nil { - return comments.Thread{}, err - } - - args := []string{ - "api", - "-X", - "POST", - fmt.Sprintf("repos/%s/%s/pulls/%s/comments", org, repo, number), - "--hostname", - s.githubHost, - "--raw-field", - "body=" + body, - "--raw-field", - "commit_id=" + sha, - "--raw-field", - "path=" + path, - "--raw-field", - "side=" + githubSide(endSide), - "--field", - "line=" + strconv.Itoa(endLine), - } - if endLine != line || endSide != side { - args = append(args, - "--field", - "start_line="+strconv.Itoa(line), - "--raw-field", - "start_side="+githubSide(side), - ) - } - out, err := ghOutput(ctx, "gh api create pull request comment", args...) - if err != nil { - return comments.Thread{}, err - } - var created githubCreatedComment - if err := json.Unmarshal(out, &created); err != nil { - return comments.Thread{}, err - } - return s.findPullRequestThread(ctx, org, repo, number, func(thread comments.Thread) bool { - for _, comment := range thread.Comments { - if comment.ID == created.NodeID || comment.ID == strconv.FormatInt(created.ID, 10) { - return true - } - } - return false - }) -} - -func (s *Server) addPullRequestReply(ctx context.Context, org, repo, number, threadID string, input comments.AddReplyInput) (comments.Thread, error) { - body := strings.TrimSpace(input.Body) - if body == "" { - return comments.Thread{}, errors.New("body is required") - } - thread, err := s.findPullRequestThread(ctx, org, repo, number, func(thread comments.Thread) bool { - return thread.ID == threadID - }) - if err != nil { - return comments.Thread{}, err - } - if thread.ReplyToID == 0 { - return comments.Thread{}, errors.New("pull request thread has no reply target") - } - _, err = ghOutput(ctx, "gh api create pull request comment reply", - "api", - "-X", - "POST", - fmt.Sprintf("repos/%s/%s/pulls/%s/comments/%d/replies", org, repo, number, thread.ReplyToID), - "--hostname", - s.githubHost, - "--raw-field", - "body="+body, - ) - if err != nil { - return comments.Thread{}, err - } - return s.findPullRequestThread(ctx, org, repo, number, func(next comments.Thread) bool { - return next.ID == threadID - }) -} - -func (s *Server) setPullRequestThreadResolved(ctx context.Context, org, repo, number, threadID string, resolved bool) (comments.Thread, error) { - mutation := resolveReviewThreadMutation - label := "gh api resolve review thread" - if !resolved { - mutation = unresolveReviewThreadMutation - label = "gh api unresolve review thread" - } - _, err := ghOutput(ctx, label, - "api", - "graphql", - "--hostname", - s.githubHost, - "-f", - "query="+mutation, - "-F", - "threadID="+threadID, - ) - if err != nil { - return comments.Thread{}, err - } - return s.findPullRequestThread(ctx, org, repo, number, func(thread comments.Thread) bool { - return thread.ID == threadID - }) -} - -func (s *Server) findPullRequestThread(ctx context.Context, org, repo, number string, match func(comments.Thread) bool) (comments.Thread, error) { - threads, err := s.listPullRequestComments(ctx, org, repo, number) - if err != nil { - return comments.Thread{}, err - } - for _, thread := range threads { - if match(thread) { - return thread, nil - } - } - return comments.Thread{}, comments.ErrNotFound -} - -func (s *Server) pullRequestHeadSHA(ctx context.Context, org, repo, number string) (string, error) { - response, err := s.pullRequest(ctx, org, repo, number) - if err != nil { - return "", err - } - if response.Head.SHA == "" { - return "", errors.New("pull request head sha is missing") - } - return response.Head.SHA, nil -} - -func (s *Server) pullRequestInfo(ctx context.Context, org, repo, number string) (pullRequestInfoResponse, error) { - response, err := s.pullRequest(ctx, org, repo, number) - if err != nil { - return pullRequestInfoResponse{}, err - } - return pullRequestInfoResponse{ - Title: response.Title, - State: response.State, - Draft: response.Draft, - Merged: response.Merged, - Author: commentAuthor(githubReviewComment{Author: response.User}), - CreatedAt: response.CreatedAt, - UpdatedAt: response.UpdatedAt, - Additions: response.Additions, - Deletions: response.Deletions, - ChangedFiles: response.Changed, - Commits: response.Commits, - HeadRef: response.Head.Ref, - HeadLabel: response.Head.Label, - HeadRepo: response.Head.Repo.FullName, - BaseRef: response.Base.Ref, - BaseLabel: response.Base.Label, - BaseRepo: response.Base.Repo.FullName, - }, nil -} - -func (s *Server) pullRequest(ctx context.Context, org, repo, number string) (githubPullResponse, error) { - out, err := ghOutput(ctx, "gh api pull request", - "api", - fmt.Sprintf("repos/%s/%s/pulls/%s", org, repo, number), - "--hostname", - s.githubHost, - ) - if err != nil { - return githubPullResponse{}, err - } - var response githubPullResponse - if err := json.Unmarshal(out, &response); err != nil { - return githubPullResponse{}, err - } - return response, nil -} - -func convertGitHubThread(thread githubReviewThread) (comments.Thread, bool) { - if thread.ID == "" || len(thread.Comments.Nodes) == 0 { - return comments.Thread{}, false - } - first := thread.Comments.Nodes[0] - last := thread.Comments.Nodes[len(thread.Comments.Nodes)-1] - path := thread.Path - line := thread.Line - if thread.StartLine > 0 { - line = thread.StartLine - } - if path == "" || line < 1 { - return comments.Thread{}, false - } - side := commentSide(thread.StartDiffSide) - if side == "" { - side = commentSide(thread.DiffSide) - } - if side == "" { - side = comments.DefaultSide - } - endLine := thread.Line - if endLine == 0 { - endLine = line - } - endSide := commentSide(thread.DiffSide) - if endSide == "" { - endSide = side - } - - status := "open" - if thread.IsResolved { - status = "resolved" - } - converted := comments.Thread{ - ID: thread.ID, - Provider: "github", - Path: path, - Side: side, - Line: line, - Status: status, - CreatedAt: first.CreatedAt, - UpdatedAt: last.CreatedAt, - ReplyToID: first.DatabaseID, - URL: first.URL, - Comments: make([]comments.Comment, 0, len(thread.Comments.Nodes)), - } - if endLine != line || endSide != side { - converted.EndLine = endLine - converted.EndSide = endSide - } - for _, comment := range thread.Comments.Nodes { - converted.Comments = append(converted.Comments, comments.Comment{ - ID: commentID(comment), - Author: commentAuthor(comment), - Body: comment.Body, - CreatedAt: comment.CreatedAt, - }) - } - return converted, true -} - -func commentID(comment githubReviewComment) string { - if comment.ID != "" { - return comment.ID - } - if comment.DatabaseID != 0 { - return strconv.FormatInt(comment.DatabaseID, 10) - } - return "" -} - -func commentAuthor(comment githubReviewComment) string { - if comment.Author != nil && comment.Author.Login != "" { - return comment.Author.Login - } - return "github" -} - -func commentSide(side string) string { - switch side { - case "RIGHT": - return "additions" - case "LEFT": - return "deletions" - default: - return "" - } -} - -func githubSide(side string) string { - if side == "deletions" { - return "LEFT" - } - return "RIGHT" -} - -func defaultRunGH(ctx context.Context, args ...string) ([]byte, error) { - return exec.CommandContext(ctx, "gh", args...).Output() -} - -func ghOutput(ctx context.Context, label string, args ...string) ([]byte, error) { - out, err := runGH(ctx, args...) - if err != nil { - return nil, commandError(label, err, nil, "") - } - return out, nil -} - -const reviewThreadsQuery = ` -query($owner: String!, $name: String!, $number: Int!, $cursor: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewThreads(first: 100, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - id - isResolved - path - line - diffSide - startLine - startDiffSide - comments(first: 100) { - nodes { - id - databaseId - author { - login - } - body - url - createdAt - } - } - } - } - } - } -}` - -const resolveReviewThreadMutation = ` -mutation($threadID: ID!) { - resolveReviewThread(input: {threadId: $threadID}) { - thread { - id - isResolved - } - } -}` - -const unresolveReviewThreadMutation = ` -mutation($threadID: ID!) { - unresolveReviewThread(input: {threadId: $threadID}) { - thread { - id - isResolved - } - } -}` diff --git a/internal/server/server.go b/internal/server/server.go deleted file mode 100644 index bf037c6..0000000 --- a/internal/server/server.go +++ /dev/null @@ -1,838 +0,0 @@ -package server - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "os/exec" - "path" - "path/filepath" - "regexp" - "strings" - "time" - - "github.com/imfing/diffs-cli/internal/appconfig" - "github.com/imfing/diffs-cli/internal/comments" - gitcmd "github.com/imfing/diffs-cli/internal/git" - "github.com/imfing/diffs-cli/internal/webassets" -) - -const ( - DefaultGitHubHost = "github.com" - gitDevNull = "/dev/null" - // The PR UI should match GitHub's final Files changed diff, not the per-commit patch stream. - githubDiffMedia = "application/vnd.github.v3.diff" - // Bounds the gh/git calls behind the lazy repo-context lookup. - repoContextTimeout = 8 * time.Second -) - -type Config struct { - CWD string - GitHubHost string - OnChange func([]ChangedFile) - UI appconfig.UIConfig - Watch bool -} - -type Server struct { - cwd string - githubHost string - staticFS fs.FS - ui appconfig.UIConfig - comments *comments.Store - events *changeBroadcaster - watcher *localWatcher -} - -type configResponse struct { - CWD string `json:"cwd"` - GitBranch string `json:"gitBranch"` - GitHubHost string `json:"githubHost"` - ColorScheme string `json:"colorScheme,omitempty"` - DiffTheme string `json:"diffTheme,omitempty"` - DiffStyle string `json:"diffStyle,omitempty"` - UIFontFamily string `json:"uiFontFamily,omitempty"` - CodeFontFamily string `json:"codeFontFamily,omitempty"` - WordWrap *bool `json:"wordWrap,omitempty"` - LineNumbers *bool `json:"lineNumbers,omitempty"` - LineBackgrounds *bool `json:"lineBackgrounds,omitempty"` -} - -type gitCommandSpec struct { - label string - args []string -} - -type commentTarget struct { - local bool - org string - repo string - number string -} - -func New(cfg Config) (http.Handler, error) { - cwd := cfg.CWD - if cwd == "" { - cwd = "." - } - absCWD, err := filepath.Abs(cwd) - if err != nil { - return nil, err - } - host := strings.TrimSpace(cfg.GitHubHost) - if host == "" { - host = DefaultGitHubHost - } - ui := appconfig.NormalizeUIConfig(cfg.UI) - staticFS, err := webassets.DistFS() - if err != nil { - return nil, err - } - commentStore, commentErr := comments.NewStore(absCWD) - if cfg.Watch && commentErr != nil { - return nil, commentErr - } - events := newChangeBroadcaster() - notifyChange := func(paths []string) { - gitStateChanged := hasGitStateEvent(paths) - status, err := gitStatus(absCWD) - var changed []ChangedFile - if err == nil { - changed = changedFilesForEvents(paths, status) - } else { - changed = changedFilesFromEvents(paths) - } - if len(changed) == 0 { - if gitStateChanged { - events.broadcast() - if cfg.OnChange != nil { - cfg.OnChange(nil) - } - } - return - } - events.broadcast() - if cfg.OnChange != nil { - cfg.OnChange(changed) - } - } - var watcher *localWatcher - if cfg.Watch { - watcher, err = newLocalWatcher(absCWD, notifyChange) - if err != nil { - return nil, err - } - } - s := &Server{ - cwd: absCWD, - githubHost: host, - staticFS: staticFS, - ui: ui, - comments: commentStore, - events: events, - watcher: watcher, - } - - mux := http.NewServeMux() - mux.HandleFunc("GET /api/config", s.handleConfig) - mux.HandleFunc("GET /api/events", s.handleEvents) - mux.HandleFunc("GET /api/local-diff", s.handleLocalDiff) - mux.HandleFunc("GET /api/branch-diff", s.handleBranchDiff) - mux.HandleFunc("GET /api/repo-context", s.handleRepoContext) - mux.HandleFunc("GET /api/comments", s.handleListComments) - mux.HandleFunc("POST /api/comments", s.handleAddComment) - mux.HandleFunc("DELETE /api/comments/{threadID}", s.handleDeleteComment) - mux.HandleFunc("POST /api/comments/{threadID}/replies", s.handleReplyComment) - mux.HandleFunc("POST /api/comments/{threadID}/resolve", s.handleResolveComment) - mux.HandleFunc("POST /api/comments/{threadID}/reopen", s.handleReopenComment) - mux.HandleFunc("GET /api/pull/{org}/{repo}/{number}", s.handlePullRequestInfo) - mux.HandleFunc("GET /api/patch/{org}/{repo}/{number}", s.handlePatch) - mux.HandleFunc("/", s.handleStatic) - return mux, nil -} - -func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { - config := configResponse{ - CWD: s.cwd, - GitBranch: s.gitBranch(r.Context()), - GitHubHost: s.githubHost, - } - if appconfig.IsColorScheme(s.ui.ColorScheme) { - config.ColorScheme = s.ui.ColorScheme - } - if appconfig.IsDiffTheme(s.ui.DiffTheme) { - config.DiffTheme = s.ui.DiffTheme - } - if appconfig.IsDiffStyle(s.ui.DiffStyle) { - config.DiffStyle = s.ui.DiffStyle - } - if s.ui.UIFontFamily != "" { - config.UIFontFamily = s.ui.UIFontFamily - } - if s.ui.CodeFontFamily != "" { - config.CodeFontFamily = s.ui.CodeFontFamily - } - if s.ui.WordWrap != nil { - config.WordWrap = s.ui.WordWrap - } - if s.ui.LineNumbers != nil { - config.LineNumbers = s.ui.LineNumbers - } - if s.ui.LineBackgrounds != nil { - config.LineBackgrounds = s.ui.LineBackgrounds - } - writeJSON(w, http.StatusOK, config) -} - -func (s *Server) handleLocalDiff(w http.ResponseWriter, r *http.Request) { - patch, err := s.localDiff(r.Context()) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = io.WriteString(w, patch) -} - -func (s *Server) handleBranchDiff(w http.ResponseWriter, r *http.Request) { - base := strings.TrimSpace(r.URL.Query().Get("base")) - if base == "" { - writeError(w, http.StatusBadRequest, errors.New("base query parameter is required")) - return - } - if !isSafeRefArg(base) { - writeError(w, http.StatusBadRequest, fmt.Errorf("invalid base ref: %q", base)) - return - } - patch, err := s.branchDiff(r.Context(), base, branchDirtyEnabled(r.URL.Query().Get("dirty"))) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = io.WriteString(w, patch) -} - -// repoContextResponse carries the optional GitHub/branch links the toolbar -// menu and local empty state surface "when applicable". Every field is -// best-effort: an empty value means "not available", so the UI just hides that -// action rather than showing an error. -type repoContextResponse struct { - // Canonical GitHub URL of the repo (e.g. https://github.com/org/repo). - RepoURL string `json:"repoUrl,omitempty"` - // GitHub URL of the pull request open for the current branch, if any. - PRURL string `json:"prUrl,omitempty"` - // Inferred base ref for `diffs branch`-style diffing (PR base -> repo - // default -> main/master), validated against the local repo. - BranchBase string `json:"branchBase,omitempty"` -} - -// handleRepoContext resolves GitHub repo/PR links and a branch base for the -// local repository, so the toolbar can offer context-aware actions without the -// user knowing the URLs or base ref. Fetched lazily by the client (on menu open -// / empty state) so the gh/git lookups never slow the normal page load. One gh -// call per resource (PR, repo) — each requests every field it needs at once. -func (s *Server) handleRepoContext(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), repoContextTimeout) - defer cancel() - - var pr struct { - URL string `json:"url"` - BaseRefName string `json:"baseRefName"` - } - _ = json.Unmarshal(s.ghJSON(ctx, "pr", "view", "--json", "url,baseRefName"), &pr) - - var repo struct { - URL string `json:"url"` - DefaultBranchRef struct { - Name string `json:"name"` - } `json:"defaultBranchRef"` - } - _ = json.Unmarshal(s.ghJSON(ctx, "repo", "view", "--json", "url,defaultBranchRef"), &repo) - - writeJSON(w, http.StatusOK, repoContextResponse{ - RepoURL: repo.URL, - PRURL: pr.URL, - BranchBase: s.resolveBranchBase(ctx, pr.BaseRefName, repo.DefaultBranchRef.Name), - }) -} - -// resolveBranchBase mirrors the `diffs branch` CLI inference: the first of the -// PR base, repo default, then main/master that resolves to a commit locally -// (or as origin/). Returns "" when none do. The PR/default refs are passed -// in (already fetched by the caller) so this stays pure git and easy to test. -func (s *Server) resolveBranchBase(ctx context.Context, prBase, repoDefault string) string { - for _, candidate := range []string{prBase, repoDefault, "main", "master"} { - if candidate == "" { - continue - } - if ref, ok := s.resolveLocalRef(ctx, candidate); ok { - return ref - } - } - return "" -} - -// resolveLocalRef mirrors the CLI helper: a ref counts only if it resolves to a -// commit locally, falling back to origin/ for inferred bases that exist -// only as a remote-tracking ref in fresh clones. -func (s *Server) resolveLocalRef(ctx context.Context, ref string) (string, bool) { - if s.gitRefExists(ctx, ref) { - return ref, true - } - if candidate := "origin/" + ref; s.gitRefExists(ctx, candidate) { - return candidate, true - } - return "", false -} - -func (s *Server) gitRefExists(ctx context.Context, ref string) bool { - return s.gitOK(ctx, "rev-parse", "--verify", "--quiet", ref+"^{commit}") -} - -// ghJSON runs gh in the repository directory and returns its raw stdout, or nil -// on any failure (gh absent, no PR, not a GitHub remote, timeout). Callers -// json.Unmarshal the result, tolerating nil as an empty object. -func (s *Server) ghJSON(ctx context.Context, args ...string) []byte { - cmd := exec.CommandContext(ctx, "gh", args...) - cmd.Dir = s.cwd - out, err := cmd.Output() - if err != nil { - return nil - } - return out -} - -func (s *Server) handlePullRequestInfo(w http.ResponseWriter, r *http.Request) { - org, repo, number, ok := prPathValues(w, r) - if !ok { - return - } - info, err := s.pullRequestInfo(r.Context(), org, repo, number) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - writeJSON(w, http.StatusOK, info) -} - -func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - if !target.local { - threads, err := s.listPullRequestComments(r.Context(), target.org, target.repo, target.number) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"threads": threads}) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - threads, err := store.List(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"threads": threads}) -} - -func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - var input comments.AddThreadInput - if err := readJSON(r, &input); err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - if !target.local { - thread, err := s.addPullRequestComment(r.Context(), target.org, target.repo, target.number, input) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - writeJSON(w, http.StatusCreated, thread) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - thread, err := store.AddThread(r.Context(), input) - if err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - writeJSON(w, http.StatusCreated, thread) -} - -func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - if !target.local { - writeError(w, http.StatusBadRequest, errors.New("deleting GitHub comments is not supported")) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - err := store.Delete(r.Context(), r.PathValue("threadID")) - if errors.Is(err, comments.ErrNotFound) { - writeError(w, http.StatusNotFound, err) - return - } - if err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) handleReplyComment(w http.ResponseWriter, r *http.Request) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - var input comments.AddReplyInput - if err := readJSON(r, &input); err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - if !target.local { - thread, err := s.addPullRequestReply(r.Context(), target.org, target.repo, target.number, r.PathValue("threadID"), input) - writeThreadOrError(w, thread, err) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - thread, err := store.AddReply(r.Context(), r.PathValue("threadID"), input) - writeThreadOrError(w, thread, err) -} - -func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) { - s.handleSetResolved(w, r, true) -} - -func (s *Server) handleReopenComment(w http.ResponseWriter, r *http.Request) { - s.handleSetResolved(w, r, false) -} - -func (s *Server) handleSetResolved(w http.ResponseWriter, r *http.Request, resolved bool) { - target, ok := s.commentTarget(w, r) - if !ok { - return - } - if !target.local { - thread, err := s.setPullRequestThreadResolved(r.Context(), target.org, target.repo, target.number, r.PathValue("threadID"), resolved) - writeThreadOrError(w, thread, err) - return - } - store, ok := s.requireComments(w) - if !ok { - return - } - var ( - thread comments.Thread - err error - ) - if resolved { - thread, err = store.Resolve(r.Context(), r.PathValue("threadID")) - } else { - thread, err = store.Reopen(r.Context(), r.PathValue("threadID")) - } - writeThreadOrError(w, thread, err) -} - -func (s *Server) requireComments(w http.ResponseWriter) (*comments.Store, bool) { - if s.comments == nil { - writeError(w, http.StatusServiceUnavailable, errors.New("local comments require a git repository")) - return nil, false - } - return s.comments, true -} - -func writeThreadOrError(w http.ResponseWriter, thread comments.Thread, err error) { - if errors.Is(err, comments.ErrNotFound) { - writeError(w, http.StatusNotFound, err) - return - } - if err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - writeJSON(w, http.StatusOK, thread) -} - -func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { - flusher, ok := w.(http.Flusher) - if !ok { - writeError(w, http.StatusInternalServerError, errors.New("streaming is not supported")) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - - events := s.events.subscribe(r.Context()) - _, _ = io.WriteString(w, ": connected\n\n") - flusher.Flush() - - ping := time.NewTicker(25 * time.Second) - defer ping.Stop() - - for { - select { - case <-r.Context().Done(): - return - case <-events: - _, _ = io.WriteString(w, "event: diff\ndata: {}\n\n") - flusher.Flush() - case <-ping.C: - _, _ = io.WriteString(w, ": ping\n\n") - flusher.Flush() - } - } -} - -func prPathValues(w http.ResponseWriter, r *http.Request) (string, string, string, bool) { - org := r.PathValue("org") - repo := r.PathValue("repo") - number := r.PathValue("number") - if !safePathPart(org) || !safePathPart(repo) || !pullNumber.MatchString(number) { - writeError(w, http.StatusBadRequest, errors.New("invalid pull request path")) - return "", "", "", false - } - return org, repo, number, true -} - -func (s *Server) commentTarget(w http.ResponseWriter, r *http.Request) (commentTarget, bool) { - query := r.URL.Query() - org := query.Get("org") - repo := query.Get("repo") - number := query.Get("number") - if org == "" && repo == "" && number == "" { - return commentTarget{local: true}, true - } - if !safePathPart(org) || !safePathPart(repo) || !pullNumber.MatchString(number) { - writeError(w, http.StatusBadRequest, errors.New("invalid pull request path")) - return commentTarget{}, false - } - return commentTarget{org: org, repo: repo, number: number}, true -} - -func (s *Server) handlePatch(w http.ResponseWriter, r *http.Request) { - org, repo, number, ok := prPathValues(w, r) - if !ok { - return - } - patch, err := s.pullRequestPatch(r.Context(), org, repo, number) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = io.WriteString(w, patch) -} - -func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" { - serveIndex(w, r, s.staticFS) - return - } - - cleanPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/") - if cleanPath == "." { - serveIndex(w, r, s.staticFS) - return - } - if _, err := fs.Stat(s.staticFS, cleanPath); err == nil { - http.FileServerFS(s.staticFS).ServeHTTP(w, r) - return - } - serveIndex(w, r, s.staticFS) -} - -func (s *Server) localDiff(ctx context.Context) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - hasHead := s.gitOK(ctx, "rev-parse", "--verify", "HEAD") - var patch strings.Builder - - commands := []gitCommandSpec{} - if hasHead { - commands = append(commands, gitCommandSpec{ - label: "git diff", - args: []string{"diff", "--no-ext-diff", "--patch", "--submodule=diff", "HEAD", "--"}, - }) - } else { - commands = append(commands, - gitCommandSpec{ - label: "git diff --cached", - args: []string{"diff", "--no-ext-diff", "--patch", "--submodule=diff", "--cached", "--"}, - }, - gitCommandSpec{ - label: "git diff", - args: []string{"diff", "--no-ext-diff", "--patch", "--submodule=diff", "--"}, - }, - ) - } - for _, command := range commands { - out, err := s.gitOutput(ctx, command.label, command.args...) - if err != nil { - return "", err - } - appendPatch(&patch, out) - } - - untracked, err := s.untrackedPatch(ctx) - if err != nil { - return "", err - } - appendPatch(&patch, untracked) - - return patch.String(), nil -} - -func (s *Server) branchDiff(ctx context.Context, base string, includeDirty bool) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - if includeDirty { - return s.branchDiffWithDirty(ctx, base) - } - - out, err := s.gitOutput(ctx, "git diff", - "diff", "--no-ext-diff", "--patch", "--submodule=diff", base+"...HEAD", "--") - if err != nil { - return "", err - } - return out, nil -} - -func (s *Server) branchDiffWithDirty(ctx context.Context, base string) (string, error) { - mergeBase, err := s.gitOutput(ctx, "git merge-base", "merge-base", base, "HEAD") - if err != nil { - return "", err - } - mergeBase = strings.TrimSpace(mergeBase) - if mergeBase == "" { - return "", errors.New("git merge-base returned an empty ref") - } - - // Compare merge base directly to the working tree so dirty edits replace, - // rather than duplicate, committed branch hunks for the same file. - out, err := s.gitOutput(ctx, "git diff", - "diff", "--no-ext-diff", "--patch", "--submodule=diff", mergeBase, "--") - if err != nil { - return "", err - } - - var patch strings.Builder - appendPatch(&patch, out) - untracked, err := s.untrackedPatch(ctx) - if err != nil { - return "", err - } - appendPatch(&patch, untracked) - return patch.String(), nil -} - -func branchDirtyEnabled(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -// isSafeRefArg rejects revision expressions and strings git could misinterpret -// as flags. Branch mode accepts branch-like refs, not arbitrary revspecs. -func isSafeRefArg(ref string) bool { - if ref == "" || - strings.HasPrefix(ref, "-") || - strings.Contains(ref, "..") || - strings.Contains(ref, "~") || - strings.Contains(ref, "^") || - ref == "@" || - strings.Contains(ref, "{") || - strings.Contains(ref, "}") || - strings.Contains(ref, "\\") { - return false - } - for _, r := range ref { - if r <= ' ' || r == 0x7f || r == ':' || r == '?' || r == '*' || r == '[' { - return false - } - } - return true -} - -func (s *Server) pullRequestPatch(ctx context.Context, org, repo, number string) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 90*time.Second) - defer cancel() - - endpoint := fmt.Sprintf("repos/%s/%s/pulls/%s", org, repo, number) - args := []string{ - "api", - endpoint, - "--hostname", - s.githubHost, - "-H", - "Accept: " + githubDiffMedia, - } - out, err := ghOutput(ctx, "gh api", args...) - if err != nil { - return "", err - } - return string(out), nil -} - -func (s *Server) untrackedPatch(ctx context.Context) (string, error) { - raw, err := s.gitOutput(ctx, "git ls-files", "ls-files", "--others", "--exclude-standard", "-z") - if err != nil { - return "", err - } - - var patch strings.Builder - for _, name := range strings.Split(raw, "\x00") { - if name == "" { - continue - } - out, err := s.gitDiffNoIndex(ctx, name) - if err != nil { - return "", err - } - appendPatch(&patch, out) - } - return patch.String(), nil -} - -func (s *Server) gitDiffNoIndex(ctx context.Context, name string) (string, error) { - cmd := s.gitCommand(ctx, "diff", "--no-ext-diff", "--patch", "--no-index", "--", gitDevNull, name) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - if err == nil { - return stdout.String(), nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { - return stdout.String(), nil - } - return "", commandError("git diff --no-index", err, cmd, stderr.String()) -} - -func (s *Server) gitOK(ctx context.Context, args ...string) bool { - return gitcmd.OK(ctx, s.cwd, args...) -} - -func (s *Server) gitBranch(ctx context.Context) string { - ctx, cancel := context.WithTimeout(ctx, gitcmd.DefaultTimeout) - defer cancel() - return gitcmd.Branch(ctx, s.cwd) -} - -func (s *Server) gitOutput(ctx context.Context, label string, args ...string) (string, error) { - cmd := s.gitCommand(ctx, args...) - out, err := cmd.Output() - if err != nil { - return "", commandError(label, err, cmd, "") - } - return string(out), nil -} - -func (s *Server) gitCommand(ctx context.Context, args ...string) *exec.Cmd { - return gitcmd.Command(ctx, s.cwd, args...) -} - -func appendPatch(b *strings.Builder, patch string) { - if patch == "" { - return - } - b.WriteString(patch) - if !strings.HasSuffix(patch, "\n") { - b.WriteByte('\n') - } -} - -func commandError(label string, err error, cmd *exec.Cmd, stderr string) error { - if errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("%s timed out", label) - } - if stderr = strings.TrimSpace(stderr); stderr != "" { - return fmt.Errorf("%s failed: %s", label, stderr) - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - stderr := strings.TrimSpace(string(exitErr.Stderr)) - if stderr != "" { - return fmt.Errorf("%s failed: %s", label, stderr) - } - } - if cmd != nil && cmd.Err != nil { - return fmt.Errorf("%s failed: %w", label, cmd.Err) - } - return fmt.Errorf("%s failed: %w", label, err) -} - -func serveIndex(w http.ResponseWriter, r *http.Request, staticFS fs.FS) { - data, err := fs.ReadFile(staticFS, "index.html") - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(data)) -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} - -func readJSON(r *http.Request, v any) error { - defer func() { _ = r.Body.Close() }() - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - if err := dec.Decode(v); err != nil { - return err - } - return nil -} - -func writeError(w http.ResponseWriter, status int, err error) { - writeJSON(w, status, map[string]string{"error": err.Error()}) -} - -var pullNumber = regexp.MustCompile(`^[1-9][0-9]*$`) -var safePathPartPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) - -func safePathPart(s string) bool { - if s == "" || strings.HasPrefix(s, "-") || strings.Contains(s, "..") || strings.ContainsAny(s, `/\`) { - return false - } - return safePathPartPattern.MatchString(s) -} diff --git a/internal/server/server_test.go b/internal/server/server_test.go deleted file mode 100644 index 9907b70..0000000 --- a/internal/server/server_test.go +++ /dev/null @@ -1,1149 +0,0 @@ -package server - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/fsnotify/fsnotify" - "github.com/imfing/diffs-cli/internal/appconfig" -) - -func TestMain(m *testing.M) { - for k, v := range map[string]string{ - "GIT_AUTHOR_NAME": "Test", - "GIT_AUTHOR_EMAIL": "test@example.com", - "GIT_COMMITTER_NAME": "Test", - "GIT_COMMITTER_EMAIL": "test@example.com", - } { - _ = os.Setenv(k, v) - } - os.Exit(m.Run()) -} - -func TestConfigIncludesCurrentBranch(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - git(t, dir, "checkout", "-b", "feature/local-title") - - handler, err := New(Config{CWD: dir}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/api/config", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - var got struct { - GitBranch string `json:"gitBranch"` - } - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatal(err) - } - if got.GitBranch != "feature/local-title" { - t.Fatalf("gitBranch = %q, want feature/local-title", got.GitBranch) - } -} - -func TestConfigIncludesUISettingsWhenConfigured(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - - wordWrap := true - lineNumbers := false - lineBackgrounds := true - handler, err := New(Config{ - CWD: dir, - UI: appconfig.UIConfig{ - ColorScheme: "dark", - DiffTheme: "github", - DiffStyle: "unified", - UIFontFamily: `"Inter Variable", system-ui, sans-serif`, - CodeFontFamily: `"JetBrains Mono", ui-monospace, monospace`, - WordWrap: &wordWrap, - LineNumbers: &lineNumbers, - LineBackgrounds: &lineBackgrounds, - }, - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/api/config", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - var got struct { - ColorScheme string `json:"colorScheme"` - DiffTheme string `json:"diffTheme"` - DiffStyle string `json:"diffStyle"` - UIFontFamily string `json:"uiFontFamily"` - CodeFontFamily string `json:"codeFontFamily"` - WordWrap bool `json:"wordWrap"` - LineNumbers bool `json:"lineNumbers"` - LineBackgrounds bool `json:"lineBackgrounds"` - } - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatal(err) - } - if got.ColorScheme != "dark" { - t.Fatalf("colorScheme = %q, want dark", got.ColorScheme) - } - if got.DiffTheme != "github" || got.DiffStyle != "unified" || !got.WordWrap || got.LineNumbers || !got.LineBackgrounds { - t.Fatalf("unexpected UI config: %+v", got) - } - if got.UIFontFamily != `"Inter Variable", system-ui, sans-serif` || got.CodeFontFamily != `"JetBrains Mono", ui-monospace, monospace` { - t.Fatalf("unexpected font config: %+v", got) - } -} - -func TestLocalDiffIncludesUntrackedFilesInUnbornRepo(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "new.txt"), "hello\n") - - patch, err := (&Server{cwd: dir}).localDiff(context.Background()) - if err != nil { - t.Fatalf("localDiff() error = %v", err) - } - for _, want := range []string{ - "diff --git a/new.txt b/new.txt", - "new file mode", - "--- /dev/null", - "+++ b/new.txt", - "+hello", - } { - if !strings.Contains(patch, want) { - t.Fatalf("localDiff() missing %q in patch:\n%s", want, patch) - } - } -} - -func TestGitDiffNoIndexUsesDevNullHeader(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "new.txt"), "hello\n") - - patch, err := (&Server{cwd: dir}).gitDiffNoIndex(context.Background(), "new.txt") - if err != nil { - t.Fatalf("gitDiffNoIndex() error = %v", err) - } - if !strings.Contains(patch, "--- /dev/null") { - t.Fatalf("gitDiffNoIndex() patch missing /dev/null header:\n%s", patch) - } -} - -func TestLocalDiffIncludesStagedAndUnstagedTrackedChanges(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - git(t, dir, "add", "tracked.txt") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\nthree\n") - - patch, err := (&Server{cwd: dir}).localDiff(context.Background()) - if err != nil { - t.Fatalf("localDiff() error = %v", err) - } - for _, want := range []string{"+two", "+three"} { - if !strings.Contains(patch, want) { - t.Fatalf("localDiff() missing %q in patch:\n%s", want, patch) - } - } -} - -func TestBranchDiffComparesAgainstBase(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - git(t, dir, "checkout", "-b", "feat") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "two") - - patch, err := (&Server{cwd: dir}).branchDiff(context.Background(), "main", false) - if err != nil { - t.Fatalf("branchDiff() error = %v", err) - } - if !strings.Contains(patch, "+two") { - t.Fatalf("branchDiff() missing %q in patch:\n%s", "+two", patch) - } -} - -func TestBranchDiffWithDirtyIncludesFinalWorkingTreeAndUntrackedFiles(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - writeFile(t, filepath.Join(dir, "staged.txt"), "old\n") - git(t, dir, "add", "tracked.txt", "staged.txt") - git(t, dir, "commit", "-m", "init") - git(t, dir, "checkout", "-b", "feat") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "two") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\nthree\n") - writeFile(t, filepath.Join(dir, "staged.txt"), "changed\n") - git(t, dir, "add", "staged.txt") - writeFile(t, filepath.Join(dir, "untracked.txt"), "hello\n") - - cleanPatch, err := (&Server{cwd: dir}).branchDiff(context.Background(), "main", false) - if err != nil { - t.Fatalf("branchDiff(clean) error = %v", err) - } - if !strings.Contains(cleanPatch, "+two") || strings.Contains(cleanPatch, "+three") { - t.Fatalf("branchDiff(clean) should only show committed branch changes:\n%s", cleanPatch) - } - - dirtyPatch, err := (&Server{cwd: dir}).branchDiff(context.Background(), "main", true) - if err != nil { - t.Fatalf("branchDiff(dirty) error = %v", err) - } - for _, want := range []string{ - "+three", - "+changed", - "diff --git a/untracked.txt b/untracked.txt", - "+hello", - } { - if !strings.Contains(dirtyPatch, want) { - t.Fatalf("branchDiff(dirty) missing %q in patch:\n%s", want, dirtyPatch) - } - } - if strings.Contains(dirtyPatch, "+two") { - t.Fatalf("branchDiff(dirty) should show final working tree content, not stale HEAD content:\n%s", dirtyPatch) - } -} - -func TestPullRequestPatchFetchesFinalDiff(t *testing.T) { - var gotArgs []string - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - gotArgs = append([]string(nil), args...) - return []byte("diff --git a/tracked.txt b/tracked.txt\n"), nil - }) - defer restore() - - patch, err := (&Server{githubHost: "github.example.com"}).pullRequestPatch( - context.Background(), - "org", - "repo", - "123", - ) - if err != nil { - t.Fatalf("pullRequestPatch() error = %v", err) - } - if patch != "diff --git a/tracked.txt b/tracked.txt\n" { - t.Fatalf("pullRequestPatch() = %q", patch) - } - for _, want := range []string{ - "repos/org/repo/pulls/123", - "--hostname", - "github.example.com", - "Accept: application/vnd.github.v3.diff", - } { - if !containsArg(gotArgs, want) { - t.Fatalf("gh args missing %q: %v", want, gotArgs) - } - } - if containsArg(gotArgs, "Accept: application/vnd.github.v3.patch") { - t.Fatalf("gh args should request final diff, not patch: %v", gotArgs) - } -} - -func TestHandleBranchDiffRejectsMissingOrUnsafeBase(t *testing.T) { - srv := &Server{cwd: t.TempDir()} - cases := []struct { - name string - query string - want string - }{ - {name: "missing", query: "", want: "base query parameter is required"}, - {name: "blank", query: "?base=%20%20", want: "required"}, - {name: "flag-like", query: "?base=-rf", want: "invalid base ref"}, - {name: "control char", query: "?base=ma%09in", want: "invalid base ref"}, - {name: "two dot rev", query: "?base=main..feature", want: "invalid base ref"}, - {name: "parent rev", query: "?base=HEAD~1", want: "invalid base ref"}, - {name: "peel rev", query: "?base=HEAD%5E%7Bcommit%7D", want: "invalid base ref"}, - {name: "upstream rev", query: "?base=%40%7Bupstream%7D", want: "invalid base ref"}, - {name: "at shortcut", query: "?base=%40", want: "invalid base ref"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/branch-diff"+tc.query, nil) - w := httptest.NewRecorder() - srv.handleBranchDiff(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", w.Code) - } - if !strings.Contains(w.Body.String(), tc.want) { - t.Fatalf("body = %q, want substring %q", w.Body.String(), tc.want) - } - }) - } -} - -func TestEventsStreamOnLocalFileChange(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - handler, err := New(Config{CWD: dir, Watch: true}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/events", nil) - if err != nil { - t.Fatal(err) - } - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer func() { _ = resp.Body.Close() }() - if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { - t.Fatalf("Content-Type = %q, want text/event-stream", got) - } - - seen := make(chan struct{}, 1) - go func() { - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - if strings.TrimSpace(scanner.Text()) == "event: diff" { - seen <- struct{}{} - return - } - } - }() - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - - select { - case <-seen: - case <-time.After(3 * time.Second): - t.Fatal("timed out waiting for diff event") - } -} - -func TestEventsStreamOnGitCommit(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - git(t, dir, "add", "tracked.txt") - - handler, err := New(Config{CWD: dir, Watch: true}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/events", nil) - if err != nil { - t.Fatal(err) - } - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer func() { _ = resp.Body.Close() }() - - seen := make(chan struct{}, 1) - go func() { - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - if strings.TrimSpace(scanner.Text()) == "event: diff" { - seen <- struct{}{} - return - } - } - }() - - git(t, dir, "commit", "-m", "two") - - select { - case <-seen: - case <-time.After(3 * time.Second): - t.Fatal("timed out waiting for diff event after commit") - } -} - -func TestOnChangeRunsOnLocalFileChange(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - changed := make(chan []ChangedFile, 1) - handler, err := New(Config{ - CWD: dir, - Watch: true, - OnChange: func(paths []ChangedFile) { - select { - case changed <- paths: - default: - } - }, - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - - select { - case paths := <-changed: - want := []ChangedFile{{Path: "tracked.txt", Action: ChangeModified}} - if len(paths) != len(want) || paths[0] != want[0] { - t.Fatalf("change paths = %+v, want %+v", paths, want) - } - case <-time.After(3 * time.Second): - t.Fatal("timed out waiting for change callback") - } -} - -func TestOnChangeIgnoresGitCleanBuildOutput(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, ".gitignore"), "web/dist/\n") - git(t, dir, "add", ".gitignore") - git(t, dir, "commit", "-m", "init") - writeFile(t, filepath.Join(dir, ".gitignore"), "web/dist/\n*.log\n") - if err := os.MkdirAll(filepath.Join(dir, "web", "dist", "assets"), 0o755); err != nil { - t.Fatal(err) - } - - changed := make(chan []ChangedFile, 1) - handler, err := New(Config{ - CWD: dir, - Watch: true, - OnChange: func(paths []ChangedFile) { - select { - case changed <- paths: - default: - } - }, - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - writeFile(t, filepath.Join(dir, "web", "dist", "assets", "index.js"), "built\n") - - select { - case paths := <-changed: - t.Fatalf("change callback ran for git-ignored build output: %v", paths) - case <-time.After(400 * time.Millisecond): - } -} - -func TestEventsStreamIgnoresGitCleanBuildOutput(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, ".gitignore"), "web/dist/\n") - git(t, dir, "add", ".gitignore") - git(t, dir, "commit", "-m", "init") - if err := os.MkdirAll(filepath.Join(dir, "web", "dist", "assets"), 0o755); err != nil { - t.Fatal(err) - } - - handler, err := New(Config{CWD: dir, Watch: true}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/events", nil) - if err != nil { - t.Fatal(err) - } - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer func() { _ = resp.Body.Close() }() - - seen := make(chan struct{}, 1) - go func() { - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - if strings.TrimSpace(scanner.Text()) == "event: diff" { - seen <- struct{}{} - return - } - } - }() - - writeFile(t, filepath.Join(dir, "web", "dist", "assets", "index.js"), "built\n") - - select { - case <-seen: - t.Fatal("received diff event for git-ignored build output") - case <-time.After(400 * time.Millisecond): - } -} - -func TestWatcherDisabledDoesNotObserveLocalChanges(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - changed := make(chan []ChangedFile, 1) - handler, err := New(Config{ - CWD: dir, - OnChange: func(paths []ChangedFile) { - changed <- paths - }, - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - ts := httptest.NewServer(handler) - defer ts.Close() - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - - select { - case paths := <-changed: - t.Fatalf("change callback ran with watcher disabled: %v", paths) - case <-time.After(400 * time.Millisecond): - } -} - -func TestGitStatusReturnsChangedPaths(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, ".gitignore"), "web/dist/\n") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", ".gitignore", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - writeFile(t, filepath.Join(dir, "new.txt"), "hello\n") - if err := os.MkdirAll(filepath.Join(dir, "web", "dist"), 0o755); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(dir, "web", "dist", "bundle.js"), "built\n") - - got, err := gitStatus(dir) - if err != nil { - t.Fatalf("gitStatus() error = %v", err) - } - want := map[string]ChangeAction{ - "new.txt": ChangeAdded, - "tracked.txt": ChangeModified, - } - if len(got) != len(want) { - t.Fatalf("gitStatus() = %+v, want %+v", got, want) - } - for path, action := range want { - if got[path] != action { - t.Fatalf("gitStatus()[%q] = %q, want %q", path, got[path], action) - } - } -} - -func TestGitStatusActions(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\n") - git(t, dir, "add", "tracked.txt") - git(t, dir, "commit", "-m", "init") - - writeFile(t, filepath.Join(dir, "tracked.txt"), "one\ntwo\n") - writeFile(t, filepath.Join(dir, "new.txt"), "hello\n") - if err := os.Remove(filepath.Join(dir, "tracked.txt")); err != nil { - t.Fatal(err) - } - - got, err := gitStatus(dir) - if err != nil { - t.Fatalf("gitStatus() error = %v", err) - } - want := map[string]ChangeAction{ - "new.txt": ChangeAdded, - "tracked.txt": ChangeDeleted, - } - if len(got) != len(want) { - t.Fatalf("gitStatus() = %+v, want %+v", got, want) - } - for path, action := range want { - if got[path] != action { - t.Fatalf("gitStatus()[%q] = %q, want %q", path, got[path], action) - } - } -} - -func TestGitStatusUsesNewPathForRenames(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - writeFile(t, filepath.Join(dir, "old.txt"), "one\n") - git(t, dir, "add", "old.txt") - git(t, dir, "commit", "-m", "init") - git(t, dir, "mv", "old.txt", "new.txt") - - got, err := gitStatus(dir) - if err != nil { - t.Fatalf("gitStatus() error = %v", err) - } - if got["new.txt"] != ChangeRenamed { - t.Fatalf("gitStatus()[new.txt] = %q, want %q; full map: %+v", got["new.txt"], ChangeRenamed, got) - } - if _, ok := got["old.txt"]; ok { - t.Fatalf("gitStatus() should not key renamed file by old path: %+v", got) - } - - changed := changedFilesForEvents([]string{"new.txt"}, got) - want := []ChangedFile{{Path: "new.txt", Action: ChangeRenamed}} - if len(changed) != len(want) || changed[0] != want[0] { - t.Fatalf("changedFilesForEvents() = %+v, want %+v", changed, want) - } -} - -func TestChangedFilesForEventsKeepsActions(t *testing.T) { - got := changedFilesForEvents( - []string{"src"}, - map[string]ChangeAction{ - ".gitignore": ChangeModified, - "src/new.go": ChangeAdded, - }, - ) - want := []ChangedFile{{Path: "src/new.go", Action: ChangeAdded}} - if len(got) != len(want) || got[0] != want[0] { - t.Fatalf("changedFilesForEvents() = %+v, want %+v", got, want) - } -} - -func TestSafePathPartRejectsOptionLikeParts(t *testing.T) { - for _, part := range []string{"-h", "../org", "org/repo", `org\repo`, "bad space"} { - if safePathPart(part) { - t.Fatalf("safePathPart(%q) = true, want false", part) - } - } - for _, part := range []string{"imfing", "diffs-cli", "repo.name", "repo_name"} { - if !safePathPart(part) { - t.Fatalf("safePathPart(%q) = false, want true", part) - } - } -} - -func TestLocalCommentsAPI(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init") - git(t, dir, "config", "user.name", "Test") - git(t, dir, "checkout", "-b", "feature/comments") - handler, err := New(Config{CWD: dir}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - addReq := httptest.NewRequest(http.MethodPost, "/api/comments", bytes.NewBufferString(`{ - "path": "web/src/App.tsx", - "line": 42, - "side": "additions", - "body": "Looks odd", - "author": "agent" - }`)) - addRec := httptest.NewRecorder() - handler.ServeHTTP(addRec, addReq) - if addRec.Code != http.StatusCreated { - t.Fatalf("add status = %d, body = %s", addRec.Code, addRec.Body.String()) - } - var thread struct { - ID string `json:"id"` - Branch string `json:"branch"` - Path string `json:"path"` - Status string `json:"status"` - Comments []struct { - Author string `json:"author"` - Body string `json:"body"` - } `json:"comments"` - } - if err := json.NewDecoder(addRec.Body).Decode(&thread); err != nil { - t.Fatal(err) - } - if thread.ID == "" || thread.Branch != "feature/comments" || thread.Path != "web/src/App.tsx" || thread.Status != "open" { - t.Fatalf("unexpected thread: %+v", thread) - } - if len(thread.Comments) != 1 || thread.Comments[0].Author != "agent" || thread.Comments[0].Body != "Looks odd" { - t.Fatalf("unexpected comments: %+v", thread.Comments) - } - - replyReq := httptest.NewRequest(http.MethodPost, "/api/comments/"+thread.ID+"/replies", bytes.NewBufferString(`{"body":"Agreed"}`)) - replyRec := httptest.NewRecorder() - handler.ServeHTTP(replyRec, replyReq) - if replyRec.Code != http.StatusOK { - t.Fatalf("reply status = %d, body = %s", replyRec.Code, replyRec.Body.String()) - } - var replied struct { - Comments []struct { - Author string `json:"author"` - Body string `json:"body"` - } `json:"comments"` - } - if err := json.NewDecoder(replyRec.Body).Decode(&replied); err != nil { - t.Fatal(err) - } - if len(replied.Comments) != 2 || replied.Comments[1].Author != "Test" || replied.Comments[1].Body != "Agreed" { - t.Fatalf("unexpected reply comments: %+v", replied.Comments) - } - - resolveReq := httptest.NewRequest(http.MethodPost, "/api/comments/"+thread.ID+"/resolve", nil) - resolveRec := httptest.NewRecorder() - handler.ServeHTTP(resolveRec, resolveReq) - if resolveRec.Code != http.StatusOK { - t.Fatalf("resolve status = %d, body = %s", resolveRec.Code, resolveRec.Body.String()) - } - - listReq := httptest.NewRequest(http.MethodGet, "/api/comments", nil) - listRec := httptest.NewRecorder() - handler.ServeHTTP(listRec, listReq) - if listRec.Code != http.StatusOK { - t.Fatalf("list status = %d, body = %s", listRec.Code, listRec.Body.String()) - } - var list struct { - Threads []struct { - ID string `json:"id"` - Status string `json:"status"` - } `json:"threads"` - } - if err := json.NewDecoder(listRec.Body).Decode(&list); err != nil { - t.Fatal(err) - } - if len(list.Threads) != 1 || list.Threads[0].ID != thread.ID || list.Threads[0].Status != "resolved" { - t.Fatalf("list = %+v, want resolved thread", list.Threads) - } - - deleteReq := httptest.NewRequest(http.MethodDelete, "/api/comments/"+thread.ID, nil) - deleteRec := httptest.NewRecorder() - handler.ServeHTTP(deleteRec, deleteReq) - if deleteRec.Code != http.StatusNoContent { - t.Fatalf("delete status = %d, body = %s", deleteRec.Code, deleteRec.Body.String()) - } - - listRec = httptest.NewRecorder() - handler.ServeHTTP(listRec, listReq) - if listRec.Code != http.StatusOK { - t.Fatalf("list after delete status = %d, body = %s", listRec.Code, listRec.Body.String()) - } - if err := json.NewDecoder(listRec.Body).Decode(&list); err != nil { - t.Fatal(err) - } - if len(list.Threads) != 0 { - t.Fatalf("list after delete = %+v, want no threads", list.Threads) - } -} - -func TestGitHubCommentsAPIListsReviewThreads(t *testing.T) { - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - if len(args) >= 2 && args[0] == "api" && args[1] == "graphql" { - return []byte(`{ - "data": { - "repository": { - "pullRequest": { - "reviewThreads": { - "pageInfo": {"hasNextPage": false, "endCursor": ""}, - "nodes": [{ - "id": "PRRT_kwDO", - "isResolved": false, - "path": "web/src/App.tsx", - "line": 42, - "comments": { - "nodes": [{ - "id": "PRRC_kwDO", - "databaseId": 1001, - "author": {"login": "octocat"}, - "body": "Looks odd", - "path": "web/src/App.tsx", - "line": 42, - "side": "RIGHT", - "url": "https://github.com/o/r/pull/1#discussion", - "createdAt": "2026-05-23T12:00:00Z" - }] - } - }] - } - } - } - } - }`), nil - } - t.Fatalf("unexpected gh args: %v", args) - return nil, nil - }) - defer restore() - - handler, err := New(Config{CWD: t.TempDir()}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/api/comments?org=org&repo=repo&number=123", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) - } - var response struct { - Threads []struct { - ID string `json:"id"` - Provider string `json:"provider"` - Path string `json:"path"` - Line int `json:"line"` - Side string `json:"side"` - Status string `json:"status"` - ReplyToID int64 `json:"replyToId"` - Comments []struct { - Author string `json:"author"` - Body string `json:"body"` - } `json:"comments"` - } `json:"threads"` - } - if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { - t.Fatal(err) - } - if len(response.Threads) != 1 { - t.Fatalf("threads = %+v, want one thread", response.Threads) - } - thread := response.Threads[0] - if thread.ID != "PRRT_kwDO" || thread.Provider != "github" || thread.Path != "web/src/App.tsx" || thread.Line != 42 || thread.Side != "additions" || thread.Status != "open" || thread.ReplyToID != 1001 { - t.Fatalf("unexpected thread: %+v", thread) - } - if len(thread.Comments) != 1 || thread.Comments[0].Author != "octocat" || thread.Comments[0].Body != "Looks odd" { - t.Fatalf("unexpected comments: %+v", thread.Comments) - } -} - -func TestGitHubPullRequestInfo(t *testing.T) { - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - if strings.Contains(strings.Join(args, " "), "repos/org/repo/pulls/123") { - return []byte(`{ - "title": "Add compact PR header", - "state": "open", - "draft": false, - "merged": false, - "user": {"login": "octocat"}, - "created_at": "2026-05-22T12:00:00Z", - "updated_at": "2026-05-23T12:00:00Z", - "additions": 10, - "deletions": 2, - "changed_files": 3, - "commits": 4, - "head": { - "sha": "abc123", - "ref": "feature", - "label": "contrib:feature", - "repo": {"full_name": "contrib/repo"} - }, - "base": { - "ref": "main", - "label": "org:main", - "repo": {"full_name": "org/repo"} - } - }`), nil - } - t.Fatalf("unexpected gh args: %v", args) - return nil, nil - }) - defer restore() - - handler, err := New(Config{CWD: t.TempDir()}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/api/pull/org/repo/123", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) - } - var got struct { - Title string `json:"title"` - State string `json:"state"` - Author string `json:"author"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - ChangedFiles int `json:"changedFiles"` - Commits int `json:"commits"` - HeadRef string `json:"headRef"` - HeadLabel string `json:"headLabel"` - HeadRepo string `json:"headRepo"` - BaseRef string `json:"baseRef"` - BaseLabel string `json:"baseLabel"` - BaseRepo string `json:"baseRepo"` - } - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatal(err) - } - if got.Title != "Add compact PR header" || got.State != "open" || got.Author != "octocat" || - got.Additions != 10 || got.Deletions != 2 || got.ChangedFiles != 3 || got.Commits != 4 || - got.HeadRef != "feature" || got.HeadLabel != "contrib:feature" || got.HeadRepo != "contrib/repo" || - got.BaseRef != "main" || got.BaseLabel != "org:main" || got.BaseRepo != "org/repo" { - t.Fatalf("unexpected pull request info: %+v", got) - } -} - -func TestGitHubCommentsAPICreatesReviewComment(t *testing.T) { - var createdArgs []string - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - joined := strings.Join(args, " ") - switch { - case strings.Contains(joined, "repos/org/repo/pulls/123") && !strings.Contains(joined, "comments"): - return []byte(`{"head":{"sha":"abc123"}}`), nil - case strings.Contains(joined, "repos/org/repo/pulls/123/comments"): - createdArgs = append([]string(nil), args...) - return []byte(`{"id":1001,"node_id":"PRRC_kwDO"}`), nil - case len(args) >= 2 && args[0] == "api" && args[1] == "graphql": - return []byte(githubReviewThreadsFixture(false)), nil - default: - t.Fatalf("unexpected gh args: %v", args) - return nil, nil - } - }) - defer restore() - - handler, err := New(Config{CWD: t.TempDir()}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/api/comments?org=org&repo=repo&number=123", bytes.NewBufferString(`{ - "path": "web/src/App.tsx", - "line": 40, - "endLine": 42, - "side": "additions", - "body": "Looks odd" - }`)) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) - } - for _, want := range []string{ - "body=Looks odd", - "commit_id=abc123", - "path=web/src/App.tsx", - "line=42", - "start_line=40", - } { - if !containsArg(createdArgs, want) { - t.Fatalf("create args missing %q: %v", want, createdArgs) - } - } -} - -func TestGitHubCommentsAPIResolvesReviewThread(t *testing.T) { - var sawResolve bool - restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) { - joined := strings.Join(args, " ") - if strings.Contains(joined, "resolveReviewThread") { - sawResolve = true - if !containsArg(args, "threadID=PRRT_kwDO") { - t.Fatalf("resolve args missing thread id: %v", args) - } - return []byte(`{"data":{"resolveReviewThread":{"thread":{"id":"PRRT_kwDO","isResolved":true}}}}`), nil - } - if len(args) >= 2 && args[0] == "api" && args[1] == "graphql" { - return []byte(githubReviewThreadsFixture(true)), nil - } - t.Fatalf("unexpected gh args: %v", args) - return nil, nil - }) - defer restore() - - handler, err := New(Config{CWD: t.TempDir()}) - if err != nil { - t.Fatalf("New() error = %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/api/comments/PRRT_kwDO/resolve?org=org&repo=repo&number=123", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) - } - if !sawResolve { - t.Fatal("resolveReviewThread mutation was not called") - } - var thread struct { - Status string `json:"status"` - } - if err := json.NewDecoder(rec.Body).Decode(&thread); err != nil { - t.Fatal(err) - } - if thread.Status != "resolved" { - t.Fatalf("status = %q, want resolved", thread.Status) - } -} - -func TestLocalWatcherIgnoresChmodEvents(t *testing.T) { - dir := t.TempDir() - w := &localWatcher{cwd: dir} - event := fsnotify.Event{Name: filepath.Join(dir, "tracked.txt"), Op: fsnotify.Chmod} - - if w.shouldSchedule(event) { - t.Fatal("chmod-only events should not schedule refreshes") - } -} - -func TestLocalWatcherIgnoresGitDirectory(t *testing.T) { - dir := t.TempDir() - w := &localWatcher{cwd: dir} - name := filepath.Join(dir, ".git", "HEAD") - - if !w.ignore(name) { - t.Fatal(".git paths should be ignored") - } -} - -func TestLocalWatcherIgnoresCommentTempFiles(t *testing.T) { - dir := t.TempDir() - w := &localWatcher{cwd: dir} - name := filepath.Join(dir, ".diffs", ".comments-123.json") - - if !w.ignore(name) { - t.Fatal("comment temp files should be ignored") - } -} - -func TestResolveBranchBaseInfersInPriorityOrder(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "main") - writeFile(t, filepath.Join(dir, "f.txt"), "one\n") - git(t, dir, "add", "f.txt") - git(t, dir, "commit", "-m", "init") - git(t, dir, "checkout", "-b", "feature") - // A base that exists only as a remote-tracking ref (origin/). - git(t, dir, "update-ref", "refs/remotes/origin/release", "HEAD") - - srv := &Server{cwd: dir} - tests := []struct { - name string - prBase string - repoDefault string - want string - }{ - {"pr base wins over repo default", "main", "release", "main"}, - {"repo default when no pr base", "", "main", "main"}, - {"main/master fallback when neither given", "", "", "main"}, - {"origin/ fallback for remote-only ref", "release", "", "origin/release"}, - {"skips unresolvable refs", "ghost", "main", "main"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := srv.resolveBranchBase(context.Background(), tt.prBase, tt.repoDefault) - if got != tt.want { - t.Fatalf("resolveBranchBase(%q, %q) = %q, want %q", tt.prBase, tt.repoDefault, got, tt.want) - } - }) - } -} - -func TestResolveBranchBaseReturnsEmptyWhenNothingResolves(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-b", "trunk") - writeFile(t, filepath.Join(dir, "f.txt"), "one\n") - git(t, dir, "add", "f.txt") - git(t, dir, "commit", "-m", "init") - - // No main/master and no origin remote, so every candidate fails. - if got := (&Server{cwd: dir}).resolveBranchBase(context.Background(), "", ""); got != "" { - t.Fatalf("resolveBranchBase() = %q, want empty", got) - } -} - -func git(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) - } -} - -func writeFile(t *testing.T, name, content string) { - t.Helper() - if err := os.WriteFile(name, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func stubGH(t *testing.T, fn func(context.Context, ...string) ([]byte, error)) func() { - t.Helper() - previous := runGH - runGH = fn - return func() { - runGH = previous - } -} - -func containsArg(args []string, want string) bool { - for _, arg := range args { - if arg == want { - return true - } - } - return false -} - -func githubReviewThreadsFixture(resolved bool) string { - return fmt.Sprintf(`{ - "data": { - "repository": { - "pullRequest": { - "reviewThreads": { - "pageInfo": {"hasNextPage": false, "endCursor": ""}, - "nodes": [{ - "id": "PRRT_kwDO", - "isResolved": %t, - "path": "web/src/App.tsx", - "line": 42, - "comments": { - "nodes": [{ - "id": "PRRC_kwDO", - "databaseId": 1001, - "author": {"login": "octocat"}, - "body": "Looks odd", - "path": "web/src/App.tsx", - "line": 42, - "side": "RIGHT", - "url": "https://github.com/o/r/pull/1#discussion", - "createdAt": "2026-05-23T12:00:00Z" - }] - } - }] - } - } - } - } - }`, resolved) -} diff --git a/internal/server/watch.go b/internal/server/watch.go deleted file mode 100644 index 93238a8..0000000 --- a/internal/server/watch.go +++ /dev/null @@ -1,416 +0,0 @@ -package server - -import ( - "context" - "maps" - "os" - "path/filepath" - "slices" - "strings" - "sync" - "time" - - "github.com/fsnotify/fsnotify" - gitcmd "github.com/imfing/diffs-cli/internal/git" -) - -const ( - watchDebounce = 150 * time.Millisecond - gitStatusTimeout = 2 * time.Second - gitStateEvent = ".git" -) - -type changeBroadcaster struct { - mu sync.Mutex - clients map[chan struct{}]struct{} -} - -func newChangeBroadcaster() *changeBroadcaster { - return &changeBroadcaster{clients: make(map[chan struct{}]struct{})} -} - -func (b *changeBroadcaster) subscribe(ctx context.Context) <-chan struct{} { - ch := make(chan struct{}, 1) - b.mu.Lock() - b.clients[ch] = struct{}{} - b.mu.Unlock() - - go func() { - <-ctx.Done() - b.mu.Lock() - delete(b.clients, ch) - b.mu.Unlock() - }() - - return ch -} - -func (b *changeBroadcaster) broadcast() { - b.mu.Lock() - defer b.mu.Unlock() - for ch := range b.clients { - select { - case ch <- struct{}{}: - default: - } - } -} - -type localWatcher struct { - cwd string - gitDir string - watcher *fsnotify.Watcher - - mu sync.Mutex - watched map[string]struct{} -} - -type ChangeAction string - -const ( - ChangeAdded ChangeAction = "added" - ChangeModified ChangeAction = "modified" - ChangeDeleted ChangeAction = "deleted" - ChangeRenamed ChangeAction = "renamed" -) - -type ChangedFile struct { - Path string - Action ChangeAction -} - -func newLocalWatcher(cwd string, notify func([]string)) (*localWatcher, error) { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - - w := &localWatcher{ - cwd: cwd, - watcher: watcher, - watched: make(map[string]struct{}), - } - if err := w.addDirRecursive(cwd); err != nil { - _ = watcher.Close() - return nil, err - } - w.gitDir = absoluteGitDir(cwd) - w.addGitStateDirs() - - go w.run(notify) - return w, nil -} - -func (w *localWatcher) run(notify func([]string)) { - var timer *time.Timer - var timerC <-chan time.Time - pending := make(map[string]struct{}) - schedule := func(name string) { - pending[w.displayName(name)] = struct{}{} - if timer == nil { - timer = time.NewTimer(watchDebounce) - timerC = timer.C - return - } - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - timer.Reset(watchDebounce) - } - - for { - select { - case event, ok := <-w.watcher.Events: - if !ok { - if timer != nil { - timer.Stop() - } - return - } - if w.isGitStateEvent(event.Name) { - if event.Has(fsnotify.Create) { - w.addGitStateDirRecursive(event.Name) - } - if w.shouldSchedule(event) { - schedule(gitStateEvent) - } - continue - } - if w.ignore(event.Name) { - continue - } - if event.Has(fsnotify.Create) { - w.addCreatedDir(event.Name) - } - if w.shouldSchedule(event) { - schedule(event.Name) - } - case _, ok := <-w.watcher.Errors: - if !ok { - if timer != nil { - timer.Stop() - } - return - } - case <-timerC: - paths := sortedKeys(pending) - pending = make(map[string]struct{}) - timerC = nil - timer = nil - notify(paths) - } - } -} - -func (w *localWatcher) shouldSchedule(event fsnotify.Event) bool { - return event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) -} - -func (w *localWatcher) addCreatedDir(name string) { - info, err := os.Stat(name) - if err != nil || !info.IsDir() { - return - } - _ = w.addDirRecursive(name) -} - -func (w *localWatcher) addDirRecursive(root string) error { - return filepath.WalkDir(root, func(name string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if !entry.IsDir() { - return nil - } - if w.skipDir(name) { - return filepath.SkipDir - } - return w.addDir(name) - }) -} - -func (w *localWatcher) addDir(name string) error { - abs, err := filepath.Abs(name) - if err != nil { - return err - } - w.mu.Lock() - defer w.mu.Unlock() - if _, ok := w.watched[abs]; ok { - return nil - } - if err := w.watcher.Add(abs); err != nil { - return err - } - w.watched[abs] = struct{}{} - return nil -} - -func (w *localWatcher) addGitStateDirs() { - if w.gitDir == "" { - return - } - for _, dir := range []string{ - w.gitDir, - filepath.Join(w.gitDir, "refs"), - filepath.Join(w.gitDir, "logs"), - } { - w.addGitStateDirRecursive(dir) - } -} - -func (w *localWatcher) addGitStateDirRecursive(root string) { - info, err := os.Stat(root) - if err != nil || !info.IsDir() { - return - } - _ = filepath.WalkDir(root, func(name string, entry os.DirEntry, err error) error { - if err != nil || !entry.IsDir() { - return nil - } - _ = w.addDir(name) - return nil - }) -} - -func (w *localWatcher) isGitStateEvent(name string) bool { - if w.gitDir == "" { - return false - } - rel, err := filepath.Rel(w.gitDir, name) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return false - } - if rel == "." { - return true - } - part := strings.Split(rel, string(filepath.Separator))[0] - switch part { - case "HEAD", "index", "index.lock", "packed-refs", "packed-refs.lock", "refs", "logs", "COMMIT_EDITMSG": - return true - default: - return false - } -} - -func (w *localWatcher) ignore(name string) bool { - if w.isCommentTempFile(name) { - return true - } - return skippedPathPart(w.cwd, name) -} - -func (w *localWatcher) skipDir(name string) bool { - if name == w.cwd { - return false - } - return skippedPathPart(w.cwd, name) -} - -func (w *localWatcher) displayName(name string) string { - rel, err := filepath.Rel(w.cwd, name) - if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { - return filepath.ToSlash(name) - } - return filepath.ToSlash(rel) -} - -func (w *localWatcher) isCommentTempFile(name string) bool { - rel, err := filepath.Rel(w.cwd, name) - if err != nil { - return false - } - return strings.HasPrefix(rel, ".diffs"+string(filepath.Separator)+".comments-") && strings.HasSuffix(rel, ".json") -} - -func sortedKeys(values map[string]struct{}) []string { - return slices.Sorted(maps.Keys(values)) -} - -func absoluteGitDir(cwd string) string { - ctx, cancel := context.WithTimeout(context.Background(), gitcmd.DefaultTimeout) - defer cancel() - - out, err := gitcmd.Run(ctx, cwd, "rev-parse", "--absolute-git-dir") - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - -func gitStatus(cwd string) (map[string]ChangeAction, error) { - ctx, cancel := context.WithTimeout(context.Background(), gitStatusTimeout) - defer cancel() - - out, err := gitcmd.Run(ctx, cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all") - if err != nil { - return nil, err - } - statusByPath := make(map[string]ChangeAction) - entries := strings.Split(string(out), "\x00") - for i := 0; i < len(entries); i++ { - entry := entries[i] - if len(entry) < 4 { - continue - } - status := entry[:2] - path := entry[3:] - if status[0] == 'R' || status[0] == 'C' { - // With porcelain -z, rename/copy records are "XY new\0old\0". - // Keep the new path as the status key and only consume the old path. - i++ - if i >= len(entries) { - continue - } - } - if path == "" || skippedPathPart(cwd, filepath.FromSlash(path)) { - continue - } - statusByPath[filepath.ToSlash(path)] = gitStatusAction(status) - } - return statusByPath, nil -} - -func hasGitStateEvent(events []string) bool { - return slices.Contains(events, gitStateEvent) -} - -func changedFilesForEvents(events []string, statusByPath map[string]ChangeAction) []ChangedFile { - if len(events) == 0 || len(statusByPath) == 0 { - return nil - } - matches := make(map[string]ChangedFile) - for _, eventPath := range events { - eventPath = cleanEventPath(eventPath) - if eventPath == "" { - continue - } - if action, ok := statusByPath[eventPath]; ok { - matches[eventPath] = ChangedFile{Path: eventPath, Action: action} - continue - } - prefix := eventPath + "/" - for path, action := range statusByPath { - if strings.HasPrefix(path, prefix) { - matches[path] = ChangedFile{Path: path, Action: action} - } - } - } - return sortedChangedFiles(matches) -} - -func changedFilesFromEvents(events []string) []ChangedFile { - files := make(map[string]ChangedFile) - for _, path := range events { - path = cleanEventPath(path) - if path == "" { - continue - } - files[path] = ChangedFile{Path: path, Action: ChangeModified} - } - return sortedChangedFiles(files) -} - -func cleanEventPath(path string) string { - return strings.Trim(filepath.ToSlash(path), "/") -} - -func gitStatusAction(status string) ChangeAction { - if strings.Contains(status, "D") { - return ChangeDeleted - } - if strings.ContainsAny(status, "RC") { - return ChangeRenamed - } - if strings.Contains(status, "A") || status == "??" { - return ChangeAdded - } - return ChangeModified -} - -func sortedChangedFiles(values map[string]ChangedFile) []ChangedFile { - paths := slices.Sorted(maps.Keys(values)) - - files := make([]ChangedFile, 0, len(paths)) - for _, path := range paths { - files = append(files, values[path]) - } - return files -} - -func skippedPathPart(root, name string) bool { - rel, err := filepath.Rel(root, name) - if err != nil || rel == "." { - return false - } - for _, part := range strings.Split(rel, string(filepath.Separator)) { - switch part { - case ".git", ".hg", ".svn", "node_modules": - return true - } - } - return false -} diff --git a/internal/webassets/dist/.gitkeep b/internal/webassets/dist/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/webassets/embed.go b/internal/webassets/embed.go deleted file mode 100644 index 1c1a116..0000000 --- a/internal/webassets/embed.go +++ /dev/null @@ -1,13 +0,0 @@ -package webassets - -import ( - "embed" - "io/fs" -) - -//go:embed all:dist -var dist embed.FS - -func DistFS() (fs.FS, error) { - return fs.Sub(dist, "dist") -} diff --git a/package.json b/package.json index 6183cbf..93367f0 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,18 @@ { "name": "diffs-cli", - "version": "0.1.0", + "version": "0.4.0", "private": true, "type": "module", "packageManager": "pnpm@10.33.2", "scripts": { - "build": "pnpm --dir web build && rm -rf internal/webassets/dist && cp -R web/dist internal/webassets/dist && touch internal/webassets/dist/.gitkeep && go build -o bin/diffs ./cmd/diffs", - "dev": "go run ./cmd/diffs", - "format": "pnpm format:go && pnpm format:web", - "format:go": "gofmt -w $(git ls-files '*.go' ':!:internal/webassets/dist/**')", + "build": "pnpm --dir web build && cargo build --release && scripts/copy-release-binary.sh", + "dev": "cargo run --", + "format": "pnpm format:rust && pnpm format:web", + "format:rust": "cargo fmt", "format:web": "pnpm --dir web format", - "lint": "pnpm lint:web && pnpm lint:go", - "lint:go": "go tool golangci-lint run", + "lint": "pnpm lint:web && pnpm lint:rust", + "lint:rust": "cargo clippy -- -D warnings", "lint:web": "pnpm --dir web lint", - "test": "pnpm --dir web lint && pnpm --dir web build && rm -rf internal/webassets/dist && cp -R web/dist internal/webassets/dist && touch internal/webassets/dist/.gitkeep && go test ./..." + "test": "pnpm --dir web lint && pnpm --dir web build && cargo test" } } diff --git a/scripts/copy-release-binary.sh b/scripts/copy-release-binary.sh new file mode 100755 index 0000000..8093d41 --- /dev/null +++ b/scripts/copy-release-binary.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh +set -eu + +src="target/release/diffs" +dest="bin/diffs" + +mkdir -p bin +cp "$src" "$dest" + +if [ "$(uname -s)" = "Darwin" ]; then + if command -v xattr >/dev/null 2>&1; then + xattr -d com.apple.provenance "$dest" 2>/dev/null || true + xattr -d com.apple.quarantine "$dest" 2>/dev/null || true + fi + if command -v codesign >/dev/null 2>&1; then + codesign --force --sign - "$dest" >/dev/null 2>&1 || true + fi +fi diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..b681b04 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,855 @@ +use crate::{comments, config, gh, git, server}; +use anyhow::{Context, bail}; +use clap::{Args, Parser, Subcommand}; +use std::{ + io::{self, IsTerminal, Read, Write}, + net::{SocketAddr, TcpListener}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +const DEFAULT_HOST: &str = "127.0.0.1"; +const DEFAULT_PORT: u16 = 3433; +const RELOAD_DEBOUNCE: Duration = Duration::from_millis(500); + +/// Error that signals a non-zero exit without printing anything (help/diagnostics +/// were already written). +#[derive(Debug)] +pub struct QuietExit; + +impl std::fmt::Display for QuietExit { + fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Ok(()) + } +} + +impl std::error::Error for QuietExit {} + +#[derive(Parser)] +#[command(name = "diffs")] +#[command(about = "Review local diffs and GitHub pull requests in a browser")] +struct Cli { + #[arg(long, default_value = ".")] + dir: PathBuf, + #[command(flatten)] + serve: ServeFlags, + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Clone, Args)] +struct ServeFlags { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + #[arg(long, default_value_t = DEFAULT_PORT)] + port: u16, + #[arg(long)] + no_open: bool, +} + +#[derive(Subcommand)] +enum Command { + #[command(about = "Review a GitHub pull request")] + Pr { + target: Option, + #[arg(long)] + gh_host: Option, + #[command(flatten)] + serve: ServeFlags, + }, + #[command(about = "Review commits on the current branch against a base")] + Branch { + base: Option, + #[arg(long)] + include_dirty: bool, + #[command(flatten)] + serve: ServeFlags, + }, + #[command(about = "Manage local review comments")] + Comments(CommentsCommand), + #[command(about = "Print version information")] + Version, +} + +#[derive(Args)] +struct CommentsCommand { + #[arg(long)] + json: bool, + #[command(subcommand)] + command: CommentSubcommand, +} + +#[derive(Subcommand)] +enum CommentSubcommand { + #[command(about = "List local comment threads for the current branch")] + List, + #[command(about = "Create a local comment thread")] + Add { + #[arg(long = "file")] + path: String, + #[arg(long)] + line: u32, + #[arg(long, default_value = comments::DEFAULT_SIDE)] + side: String, + #[arg(long)] + end_line: Option, + #[arg(long, default_value = "")] + end_side: String, + #[arg(long)] + body: String, + #[arg(long, default_value = "")] + author: String, + }, + #[command(about = "Reply to a local comment thread")] + Reply { + thread_id: String, + #[arg(long)] + body: String, + #[arg(long, default_value = "")] + author: String, + }, + #[command(about = "Resolve a local comment thread")] + Resolve { thread_id: String }, + #[command(about = "Reopen a resolved local comment thread")] + Reopen { thread_id: String }, +} + +pub async fn run(started: Instant) -> anyhow::Result<()> { + let cli = Cli::parse(); + match cli.command { + None => { + run_server_target(&cli.dir, &cli.serve, gh_host(None), "/local", true, started).await + } + Some(Command::Pr { + target, + gh_host: host, + serve, + }) => { + let args = target.into_iter().collect::>(); + let target = gh::pr_target_from_args(&args, &cli.dir).await?; + let host = gh_host(host).or_else(|| (!target.host.is_empty()).then_some(target.host)); + run_server_target(&cli.dir, &serve, host, &target.path, false, started).await + } + Some(Command::Branch { + base, + include_dirty, + serve, + }) => { + // Fail with the formatted git help before inferring a base, so a + // non-repo gives the same UX as the local command (not a confusing + // "could not infer base ref"). + resolve_repo_root_or_help(&cli.dir)?; + let base = resolve_branch_base(base, &cli.dir).await?; + let target = branch_target(&base, include_dirty); + run_server_target(&cli.dir, &serve, gh_host(None), &target, true, started).await + } + Some(Command::Comments(command)) => run_comments(&cli.dir, command), + Some(Command::Version) => { + println!("{}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + } +} + +async fn run_server_target( + dir: &Path, + flags: &ServeFlags, + github_host: Option, + target_path: &str, + watch: bool, + started: Instant, +) -> anyhow::Result<()> { + let stdout_color = colors_enabled(io::stdout().is_terminal()); + let cwd = if watch { + resolve_repo_root_or_help(dir)? + } else { + dir.to_path_buf() + }; + // Canonicalize for a clean, symlink-resolved display (no libgit2 trailing + // slash) that also matches the cwd the server resolves internally. + let cwd = std::fs::canonicalize(&cwd)?; + let app_cfg = config::load_default().context("load config")?; + let on_change: Option = + watch.then(|| new_reload_logger(stdout_color) as server::OnChange); + let running = server::new(server::ServerConfig { + cwd: cwd.clone(), + github_host: github_host.unwrap_or_else(|| gh::DEFAULT_GITHUB_HOST.to_string()), + ui: app_cfg.ui, + watch, + on_change, + })?; + let (listener, requested, actual) = bind_with_fallback(&flags.host, flags.port)?; + let url = browser_url(actual, target_path); + if requested.port() != 0 && requested != actual { + print_port_fallback(&requested.to_string(), &actual.to_string(), stdout_color); + } + print_startup( + &StartupInfo { + url: &url, + target: &target_label(target_path, &cwd), + cwd: &cwd.display().to_string(), + watching: watch, + elapsed: started.elapsed(), + }, + stdout_color, + ); + if !flags.no_open + && let Err(err) = open::that(&url) + { + eprintln!("warning: could not open browser: {err}"); + } + let listener = tokio::net::TcpListener::from_std(listener)?; + server::serve_router(listener, running.router).await +} + +// --- Terminal output --- + +struct Colors { + reset: &'static str, + dim: &'static str, + green: &'static str, + cyan: &'static str, + yellow: &'static str, + red: &'static str, + magenta: &'static str, +} + +fn colors_enabled(is_terminal: bool) -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + if std::env::var("TERM").is_ok_and(|term| term == "dumb") { + return false; + } + is_terminal +} + +fn palette(enabled: bool) -> Colors { + if enabled { + Colors { + reset: "\x1b[0m", + dim: "\x1b[2m", + green: "\x1b[32m", + cyan: "\x1b[36m", + yellow: "\x1b[33m", + red: "\x1b[31m", + magenta: "\x1b[35m", + } + } else { + Colors { + reset: "", + dim: "", + green: "", + cyan: "", + yellow: "", + red: "", + magenta: "", + } + } +} + +fn colorize(text: &str, color: &str, reset: &str) -> String { + if color.is_empty() { + text.to_string() + } else { + format!("{color}{text}{reset}") + } +} + +fn log_line(c: &Colors, label: &str, message: &str, color: &str) -> String { + let color = if color.is_empty() { c.green } else { color }; + format!(" {color}{label:<8}{} {message}", c.reset) +} + +struct StartupInfo<'a> { + url: &'a str, + target: &'a str, + cwd: &'a str, + watching: bool, + elapsed: Duration, +} + +fn print_startup(info: &StartupInfo<'_>, color: bool) { + let c = palette(color); + println!(); + println!( + "{}", + log_line( + &c, + "diffs", + &format!("ready in {}", format_ready_duration(info.elapsed)), + "", + ) + ); + println!( + "{}", + log_line(&c, "serve", &colorize(info.url, c.cyan, c.reset), "") + ); + println!("{}", log_line(&c, "target", info.target, "")); + if info.watching { + println!("{}", log_line(&c, "watch", info.cwd, "")); + } + println!( + "{}", + log_line(&c, "stop", &colorize("Ctrl+C", c.dim, c.reset), "") + ); + println!(); +} + +fn print_port_fallback(requested: &str, actual: &str, color: bool) { + let c = palette(color); + println!(); + println!( + "{}", + log_line( + &c, + "warn", + &format!("{requested} in use; using {actual}"), + c.yellow + ) + ); +} + +fn print_local_git_help(dir: &str, color: bool) { + let c = palette(color); + eprintln!(); + eprintln!( + "{}", + log_line(&c, "error", &format!("not a git repository: {dir}"), "") + ); + eprintln!("{}", log_line(&c, "hint", "run from a git repository", "")); + eprintln!( + "{}", + log_line(&c, "hint", "or pass --dir /path/to/repo", "") + ); + eprintln!( + "{}", + log_line(&c, "hint", "or use diffs pr /org/repo/pull/123", "") + ); + eprintln!(); +} + +fn format_ready_duration(elapsed: Duration) -> String { + let ms = (elapsed.as_secs_f64() * 1000.0).round() as i64; + format!("{} ms", ms.max(1)) +} + +fn new_reload_logger(color: bool) -> server::OnChange { + let last: Arc>> = Arc::new(Mutex::new(None)); + Arc::new(move |files: Vec| { + let now = Instant::now(); + { + let mut guard = last.lock().expect("reload logger lock poisoned"); + if let Some(prev) = *guard + && now.duration_since(prev) < RELOAD_DEBOUNCE + { + return; + } + *guard = Some(now); + } + print_reload(&files, color); + }) +} + +fn print_reload(files: &[git::ChangedFile], color: bool) { + let c = palette(color); + let (label, message) = reload_line(files, &c, color); + let label_color = reload_label_color(files.first().map(|f| f.action), &c); + println!("{}", log_line(&c, &label, &message, label_color)); +} + +fn reload_line(files: &[git::ChangedFile], c: &Colors, color: bool) -> (String, String) { + let Some(first) = files.first() else { + return ("change".to_string(), "local changes".to_string()); + }; + let label = first.action.as_str().to_string(); + let path = if color { + colorize(&first.path, c.cyan, c.reset) + } else { + first.path.clone() + }; + if files.len() == 1 { + (label, path) + } else { + (label, format!("{path} (+{} more)", files.len() - 1)) + } +} + +fn reload_label_color(action: Option, c: &Colors) -> &'static str { + match action { + Some(git::ChangeAction::Added) => c.green, + Some(git::ChangeAction::Modified) => c.yellow, + Some(git::ChangeAction::Deleted) => c.red, + Some(git::ChangeAction::Renamed) => c.magenta, + None => c.green, + } +} + +/// Builds the human label for the served target. +fn target_label(target_path: &str, cwd: &std::path::Path) -> String { + if target_path == "/local" { + let branch = git::branch(cwd); + return if branch.is_empty() { + "local repository".to_string() + } else { + branch + }; + } + if target_path.starts_with("/branch") { + let base = branch_base_from_target(target_path); + let head = git::branch(cwd); + let head = if head.is_empty() { "HEAD" } else { &head }; + return if base.is_empty() { + format!("{head} branch diff") + } else { + format!("{head} -> {base}") + }; + } + let parts: Vec<&str> = target_path.trim_matches('/').split('/').collect(); + if parts.len() == 4 && parts[2] == "pull" { + return format!("GitHub PR {}/{}#{}", parts[0], parts[1], parts[3]); + } + target_path.to_string() +} + +fn branch_base_from_target(target_path: &str) -> String { + let Some((_, query)) = target_path.split_once('?') else { + return String::new(); + }; + url::form_urlencoded::parse(query.as_bytes()) + .find(|(key, _)| key == "base") + .map(|(_, value)| value.into_owned()) + .unwrap_or_default() +} + +fn bind_with_fallback( + host: &str, + port: u16, +) -> anyhow::Result<(TcpListener, SocketAddr, SocketAddr)> { + let host = if host.trim().is_empty() || host.trim() == "localhost" { + DEFAULT_HOST + } else { + host.trim() + }; + let requested: SocketAddr = format!("{host}:{port}").parse()?; + match TcpListener::bind(requested) { + Ok(listener) => { + listener.set_nonblocking(true)?; + let actual = listener.local_addr()?; + Ok((listener, requested, actual)) + } + Err(err) if err.kind() == io::ErrorKind::AddrInUse && port != 0 => { + let fallback: SocketAddr = format!("{host}:0").parse()?; + let listener = TcpListener::bind(fallback)?; + listener.set_nonblocking(true)?; + let actual = listener.local_addr()?; + Ok((listener, requested, actual)) + } + Err(err) => Err(err.into()), + } +} + +fn browser_url(addr: SocketAddr, target_path: &str) -> String { + let host = if addr.ip().is_unspecified() { + DEFAULT_HOST.to_string() + } else { + addr.ip().to_string() + }; + format!("http://{host}:{}{}", addr.port(), target_path) +} + +async fn resolve_branch_base(base: Option, dir: &PathBuf) -> anyhow::Result { + if let Some(base) = base + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return Ok(base); + } + for args in [ + ["pr", "view", "--json", "baseRefName", "-q", ".baseRefName"], + [ + "repo", + "view", + "--json", + "defaultBranchRef", + "-q", + ".defaultBranchRef.name", + ], + ] { + if let Ok(candidate) = gh::run(dir, &args).await + && let Some(resolved) = git::resolve_local_ref(dir, &candidate) + { + return Ok(resolved); + } + } + for candidate in ["main", "master"] { + if let Some(resolved) = git::resolve_local_ref(dir, candidate) { + return Ok(resolved); + } + } + bail!("could not infer base ref; pass one explicitly, e.g. `diffs branch main`") +} + +fn branch_target(base: &str, include_dirty: bool) -> String { + let mut params = url::form_urlencoded::Serializer::new(String::new()); + params.append_pair("base", base); + if include_dirty { + params.append_pair("dirty", "1"); + } + format!("/branch?{}", params.finish()) +} + +/// Resolves the repository working-tree root, or prints the formatted git help +/// to stderr and returns `QuietExit` so the caller exits 1 without a duplicate +/// error line. Shared by the local and branch commands. +fn resolve_repo_root_or_help(dir: &Path) -> anyhow::Result { + match git::root(dir) { + Ok(root) => Ok(root), + Err(_) => { + print_local_git_help( + &dir.display().to_string(), + colors_enabled(io::stderr().is_terminal()), + ); + Err(QuietExit.into()) + } + } +} + +fn gh_host(flag: Option) -> Option { + flag.or_else(|| std::env::var("GH_HOST").ok()) + .map(|host| host.trim().to_string()) + .filter(|host| !host.is_empty()) +} + +fn run_comments(dir: &PathBuf, command: CommentsCommand) -> anyhow::Result<()> { + let store = comments::Store::new(dir)?; + match command.command { + CommentSubcommand::List => { + let threads = store.list()?; + if command.json { + print_json(&serde_json::json!({ "threads": threads }))?; + } else { + print_threads(&threads); + } + } + CommentSubcommand::Add { + path, + line, + side, + end_line, + end_side, + body, + author, + } => { + let thread = store.add_thread(comments::AddThreadInput { + path, + side, + line, + end_line: end_line.unwrap_or_default(), + end_side, + body: body_from_flag(body)?, + author, + })?; + print_thread_result(&thread, command.json)?; + } + CommentSubcommand::Reply { + thread_id, + body, + author, + } => { + let thread = store.add_reply( + &thread_id, + comments::AddReplyInput { + body: body_from_flag(body)?, + author, + }, + )?; + print_thread_result(&thread, command.json)?; + } + CommentSubcommand::Resolve { thread_id } => { + let thread = store.resolve(&thread_id)?; + print_thread_result(&thread, command.json)?; + } + CommentSubcommand::Reopen { thread_id } => { + let thread = store.reopen(&thread_id)?; + print_thread_result(&thread, command.json)?; + } + } + Ok(()) +} + +fn body_from_flag(body: String) -> anyhow::Result { + if body != "-" { + return Ok(body); + } + let mut data = String::new(); + io::stdin().read_to_string(&mut data)?; + Ok(data) +} + +fn print_json(value: &serde_json::Value) -> anyhow::Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn print_thread_result(thread: &comments::Thread, as_json: bool) -> anyhow::Result<()> { + if as_json { + println!("{}", serde_json::to_string_pretty(thread)?); + } else { + println!( + "{}\t{}\t{}\t{}", + thread.id, + thread.status, + thread_location(thread), + latest_comment_body(thread) + ); + } + Ok(()) +} + +fn print_threads(threads: &[comments::Thread]) { + if threads.is_empty() { + println!("No local comment threads."); + return; + } + println!("ID\tSTATUS\tLOCATION\tCOMMENTS\tLATEST"); + for thread in threads { + println!( + "{}\t{}\t{}\t{}\t{}", + thread.id, + thread.status, + thread_location(thread), + thread.comments.len(), + latest_comment_body(thread) + ); + } + let _ = io::stdout().flush(); +} + +fn thread_location(thread: &comments::Thread) -> String { + let end_line = if thread.end_line == 0 { + thread.line + } else { + thread.end_line + }; + if end_line == thread.line { + format!("{}:{}", thread.path, thread.line) + } else { + format!("{}:{}-{end_line}", thread.path, thread.line) + } +} + +fn latest_comment_body(thread: &comments::Thread) -> String { + const LIMIT: usize = 72; + let Some(comment) = thread.comments.last() else { + return String::new(); + }; + let body = comment.body.replace('\n', " "); + let mut chars = body.chars(); + let preview: String = chars.by_ref().take(LIMIT - 3).collect(); + if chars.next().is_some() { + format!("{preview}...") + } else { + body + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener as StdTcpListener; + + fn run_git(dir: &std::path::Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); + } + + #[test] + fn branch_target_encodes_base_and_dirty() { + assert_eq!( + branch_target("origin/main", false), + "/branch?base=origin%2Fmain" + ); + assert_eq!( + branch_target("origin/main", true), + "/branch?base=origin%2Fmain&dirty=1" + ); + } + + #[test] + fn branch_base_from_target_decodes_base() { + assert_eq!( + branch_base_from_target("/branch?base=origin%2Fmain"), + "origin/main" + ); + assert_eq!(branch_base_from_target("/branch?base=main&dirty=1"), "main"); + assert_eq!(branch_base_from_target("/local"), ""); + } + + #[test] + fn browser_url_uses_loopback_for_wildcard() { + let addr: SocketAddr = "0.0.0.0:3433".parse().unwrap(); + assert_eq!(browser_url(addr, "/local"), "http://127.0.0.1:3433/local"); + } + + #[test] + fn target_label_variants() { + let dir = tempfile::tempdir().unwrap(); + run_git(dir.path(), &["init"]); + run_git(dir.path(), &["checkout", "-b", "feature/startup"]); + + assert_eq!(target_label("/local", dir.path()), "feature/startup"); + assert_eq!( + target_label("/branch?base=origin%2Fmain", dir.path()), + "feature/startup -> origin/main" + ); + assert_eq!( + target_label("/org/repo/pull/123", dir.path()), + "GitHub PR org/repo#123" + ); + assert_eq!( + target_label("/local", &dir.path().join("missing")), + "local repository" + ); + } + + fn changed(action: git::ChangeAction, path: &str) -> git::ChangedFile { + git::ChangedFile { + path: path.to_string(), + action, + } + } + + #[test] + fn reload_line_summarizes_multiple_paths() { + let files = [ + changed(git::ChangeAction::Added, "a.go"), + changed(git::ChangeAction::Modified, "b.go"), + changed(git::ChangeAction::Deleted, "c.go"), + ]; + let (label, message) = reload_line(&files, &palette(false), false); + assert_eq!(label, "added"); + assert_eq!(message, "a.go (+2 more)"); + } + + #[test] + fn reload_line_colors_single_path() { + let colors = Colors { + reset: "Z", + cyan: "C", + dim: "", + green: "", + yellow: "", + red: "", + magenta: "", + }; + let files = [changed(git::ChangeAction::Modified, "a.go")]; + let (label, message) = reload_line(&files, &colors, true); + assert_eq!(label, "modified"); + assert_eq!(message, "Ca.goZ"); + } + + #[test] + fn reload_line_falls_back_to_change_label() { + let (label, message) = reload_line(&[], &palette(false), false); + assert_eq!(label, "change"); + assert_eq!(message, "local changes"); + } + + #[test] + fn reload_label_color_by_action() { + let c = palette(true); + assert_eq!( + reload_label_color(Some(git::ChangeAction::Added), &c), + c.green + ); + assert_eq!( + reload_label_color(Some(git::ChangeAction::Modified), &c), + c.yellow + ); + assert_eq!( + reload_label_color(Some(git::ChangeAction::Deleted), &c), + c.red + ); + assert_eq!( + reload_label_color(Some(git::ChangeAction::Renamed), &c), + c.magenta + ); + assert_eq!(reload_label_color(None, &c), c.green); + } + + #[test] + fn format_ready_duration_has_floor_of_one_ms() { + assert_eq!(format_ready_duration(Duration::from_millis(0)), "1 ms"); + assert_eq!(format_ready_duration(Duration::from_micros(200)), "1 ms"); + assert_eq!(format_ready_duration(Duration::from_millis(7)), "7 ms"); + } + + #[test] + fn latest_comment_body_truncates_utf8_safely() { + let body = "评".repeat(80) + " done"; + let thread = comments::Thread { + id: "t".into(), + provider: "local".into(), + branch: "main".into(), + path: "a.go".into(), + side: "additions".into(), + line: 1, + end_side: String::new(), + end_line: 0, + status: "open".into(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + comments: vec![comments::Comment { + id: "c".into(), + author: "a".into(), + body, + created_at: chrono::Utc::now(), + }], + reply_to_id: None, + url: String::new(), + }; + let got = latest_comment_body(&thread); + assert!(got.is_char_boundary(got.len())); + assert_eq!(got.matches('评').count(), 69); + assert!(got.ends_with("...")); + } + + #[test] + fn resolve_repo_root_or_help_quiet_exits_outside_repo() { + let dir = tempfile::tempdir().unwrap(); + let err = resolve_repo_root_or_help(dir.path()).unwrap_err(); + assert!( + err.downcast_ref::().is_some(), + "expected QuietExit, got: {err}" + ); + } + + #[test] + fn resolve_repo_root_or_help_returns_root_in_repo() { + let dir = tempfile::tempdir().unwrap(); + run_git(dir.path(), &["init"]); + let root = resolve_repo_root_or_help(dir.path()).unwrap(); + assert_eq!( + root.canonicalize().unwrap(), + dir.path().canonicalize().unwrap() + ); + } + + #[test] + fn bind_with_fallback_uses_random_port_when_busy() { + let busy = StdTcpListener::bind("127.0.0.1:0").unwrap(); + let port = busy.local_addr().unwrap().port(); + + let (listener, requested, actual) = bind_with_fallback("127.0.0.1", port).unwrap(); + assert_eq!(requested.port(), port); + assert_ne!(actual.port(), 0); + assert_ne!(actual.port(), port); + drop(listener); + } +} diff --git a/src/comments.rs b/src/comments.rs new file mode 100644 index 0000000..acc85ba --- /dev/null +++ b/src/comments.rs @@ -0,0 +1,682 @@ +use crate::git; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Component, Path, PathBuf}, + sync::Mutex, +}; +use thiserror::Error; + +pub const DEFAULT_AUTHOR: &str = "local"; +pub const DEFAULT_SIDE: &str = "additions"; + +#[derive(Debug, Error)] +pub enum CommentError { + #[error("comment thread not found")] + NotFound, + #[error("{0}")] + Validation(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Git(#[from] git::GitError), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Serialize, Deserialize)] +pub struct File { + pub version: u8, + pub repo: String, + #[serde(default)] + pub threads: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Thread { + pub id: String, + pub provider: String, + pub branch: String, + pub path: String, + pub side: String, + pub line: u32, + #[serde(skip_serializing_if = "String::is_empty", default)] + pub end_side: String, + #[serde(skip_serializing_if = "is_zero", default)] + pub end_line: u32, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub comments: Vec, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub reply_to_id: Option, + #[serde(skip_serializing_if = "String::is_empty", default)] + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Comment { + pub id: String, + pub author: String, + pub body: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddThreadInput { + pub path: String, + pub side: String, + pub line: u32, + #[serde(default)] + pub end_side: String, + #[serde(default)] + pub end_line: u32, + pub body: String, + #[serde(default)] + pub author: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AddReplyInput { + pub body: String, + #[serde(default)] + pub author: String, +} + +pub struct Store { + root: PathBuf, + path: PathBuf, + lock: Mutex<()>, +} + +impl Store { + pub fn new(cwd: impl AsRef) -> Result { + let root = git::root(cwd)?; + let path = root.join(".diffs").join("comments.json"); + Ok(Self { + root, + path, + lock: Mutex::new(()), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn branch(&self) -> String { + let branch = git::branch(&self.root); + if branch.is_empty() { + "local".to_string() + } else { + branch + } + } + + pub fn list(&self) -> Result> { + let _guard = self.lock.lock().expect("comment store lock poisoned"); + let file = self.load()?; + let branch = self.branch(); + Ok(file + .threads + .into_iter() + .filter(|thread| thread.branch == branch) + .collect()) + } + + pub fn add_thread(&self, input: AddThreadInput) -> Result { + let clean = clean_thread_input(input.clone())?; + let author = clean_author(&self.root, input.author); + let now = Utc::now(); + let mut thread = Thread { + id: new_id("thr"), + provider: "local".to_string(), + // Stamped under the lock below so it matches the snapshot list() + // and update_thread() read, even if a branch switch races us. + branch: String::new(), + path: clean.path, + side: clean.side.clone(), + line: clean.line, + end_side: String::new(), + end_line: 0, + status: "open".to_string(), + created_at: now, + updated_at: now, + comments: vec![Comment { + id: new_id("cmt"), + author, + body: clean.body, + created_at: now, + }], + reply_to_id: None, + url: String::new(), + }; + if clean.end_line != clean.line || clean.end_side != clean.side { + thread.end_side = clean.end_side; + thread.end_line = clean.end_line; + } + + let _guard = self.lock.lock().expect("comment store lock poisoned"); + thread.branch = self.branch(); + let mut file = self.load()?; + file.threads.push(thread.clone()); + self.save(file)?; + Ok(thread) + } + + pub fn add_reply(&self, thread_id: &str, input: AddReplyInput) -> Result { + let body = input.body.trim().to_string(); + if body.is_empty() { + return validation("body is required"); + } + let author = clean_author(&self.root, input.author); + self.update_thread(thread_id, |thread, now| { + thread.comments.push(Comment { + id: new_id("cmt"), + author, + body, + created_at: now, + }); + thread.updated_at = now; + }) + } + + pub fn resolve(&self, thread_id: &str) -> Result { + self.set_status(thread_id, "resolved") + } + + pub fn reopen(&self, thread_id: &str) -> Result { + self.set_status(thread_id, "open") + } + + pub fn delete(&self, thread_id: &str) -> Result<()> { + let thread_id = thread_id.trim(); + if thread_id.is_empty() { + return validation("thread id is required"); + } + let _guard = self.lock.lock().expect("comment store lock poisoned"); + let mut file = self.load()?; + let branch = self.branch(); + let original_len = file.threads.len(); + file.threads + .retain(|thread| thread.id != thread_id || thread.branch != branch); + if file.threads.len() == original_len { + return Err(CommentError::NotFound); + } + self.save(file) + } + + fn set_status(&self, thread_id: &str, status: &str) -> Result { + self.update_thread(thread_id, |thread, now| { + thread.status = status.to_string(); + thread.updated_at = now; + }) + } + + fn update_thread( + &self, + thread_id: &str, + update: impl FnOnce(&mut Thread, DateTime), + ) -> Result { + let thread_id = thread_id.trim(); + if thread_id.is_empty() { + return validation("thread id is required"); + } + let _guard = self.lock.lock().expect("comment store lock poisoned"); + let mut file = self.load()?; + let branch = self.branch(); + for thread in &mut file.threads { + if thread.id != thread_id || thread.branch != branch { + continue; + } + update(thread, Utc::now()); + let updated = thread.clone(); + self.save(file)?; + return Ok(updated); + } + Err(CommentError::NotFound) + } + + fn load(&self) -> Result { + let data = match fs::read_to_string(&self.path) { + Ok(data) => data, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(self.empty_file()), + Err(err) => return Err(err.into()), + }; + if data.trim().is_empty() { + return Ok(self.empty_file()); + } + let mut file: File = serde_json::from_str(&data)?; + if file.version == 0 { + file.version = 1; + } + if file.repo.is_empty() { + file.repo = self.root_string(); + } + Ok(file) + } + + fn save(&self, mut file: File) -> Result<()> { + file.version = 1; + file.repo = self.root_string(); + let dir = self + .path + .parent() + .ok_or_else(|| CommentError::Validation("comment path has no parent".to_string()))?; + fs::create_dir_all(dir)?; + let data = serde_json::to_string_pretty(&file)? + "\n"; + let tmp_name = format!(".comments-{}.json", new_id("tmp")); + let tmp_path = dir.join(tmp_name); + fs::write(&tmp_path, data)?; + fs::rename(&tmp_path, &self.path)?; + Ok(()) + } + + fn empty_file(&self) -> File { + File { + version: 1, + repo: self.root_string(), + threads: Vec::new(), + } + } + + fn root_string(&self) -> String { + self.root.to_string_lossy().to_string() + } +} + +#[derive(Debug)] +pub struct CleanThread { + pub path: String, + pub side: String, + pub line: u32, + pub end_side: String, + pub end_line: u32, + pub body: String, +} + +pub fn clean_thread_input(input: AddThreadInput) -> Result { + let mut path = input.path.trim().replace('\\', "/"); + let mut side = input.side.trim().to_string(); + let mut end_side = input.end_side.trim().to_string(); + let body = input.body.trim().to_string(); + if path.is_empty() { + return validation("path is required"); + } + if has_parent_path_segment(&path) { + return validation("path must be relative to the repository"); + } + path = clean_slash_path(&path)?; + if input.line < 1 { + return validation("line must be greater than zero"); + } + let end_line = if input.end_line == 0 { + input.line + } else { + input.end_line + }; + if end_line < 1 { + return validation("end line must be greater than zero"); + } + if end_line < input.line { + return validation("end line must be greater than or equal to line"); + } + if side.is_empty() { + side = DEFAULT_SIDE.to_string(); + } + if end_side.is_empty() { + end_side = side.clone(); + } + if side != "additions" && side != "deletions" { + return validation("side must be additions or deletions"); + } + if end_side != "additions" && end_side != "deletions" { + return validation("end side must be additions or deletions"); + } + if body.is_empty() { + return validation("body is required"); + } + Ok(CleanThread { + path, + side, + line: input.line, + end_side, + end_line, + body, + }) +} + +fn clean_slash_path(path: &str) -> Result { + let mut parts = Vec::new(); + for component in Path::new(path).components() { + match component { + Component::Normal(part) => parts.push(part.to_string_lossy().to_string()), + Component::CurDir => {} + _ => return validation("path must be relative to the repository"), + } + } + if parts.is_empty() { + return validation("path is required"); + } + Ok(parts.join("/")) +} + +fn has_parent_path_segment(path: &str) -> bool { + path.split('/').any(|part| part == "..") +} + +fn clean_author(root: &Path, author: String) -> String { + let author = author.trim(); + if !author.is_empty() { + return author.to_string(); + } + git::config_string(root, "user.name").unwrap_or_else(|| DEFAULT_AUTHOR.to_string()) +} + +fn new_id(prefix: &str) -> String { + let bytes: [u8; 8] = rand::random(); + format!("{prefix}_{}", hex::encode(bytes)) +} + +fn is_zero(value: &u32) -> bool { + *value == 0 +} + +fn validation(message: &str) -> Result { + Err(CommentError::Validation(message.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_thread_input_defaults_range_end() { + let clean = clean_thread_input(AddThreadInput { + path: " src\\main.rs ".to_string(), + line: 3, + body: " hi ".to_string(), + ..Default::default() + }) + .unwrap(); + assert_eq!(clean.path, "src/main.rs"); + assert_eq!(clean.side, "additions"); + assert_eq!(clean.end_side, "additions"); + assert_eq!(clean.end_line, 3); + assert_eq!(clean.body, "hi"); + } + + #[test] + fn clean_thread_input_rejects_parent_paths() { + let err = clean_thread_input(AddThreadInput { + path: "../secret".to_string(), + line: 1, + body: "body".to_string(), + ..Default::default() + }) + .unwrap_err(); + assert!(err.to_string().contains("path must be relative")); + } + + #[test] + fn clean_thread_input_rejects_invalid_table() { + let bad = [ + AddThreadInput { + path: "".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "../outside".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a/../b".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a/../../outside".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a\\..\\b".into(), + line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 0, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 10, + end_line: 1, + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 1, + side: "right".into(), + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 1, + end_side: "right".into(), + body: "b".into(), + ..Default::default() + }, + AddThreadInput { + path: "a.go".into(), + line: 1, + body: "".into(), + ..Default::default() + }, + ]; + for input in bad { + assert!( + clean_thread_input(input.clone()).is_err(), + "expected error for {input:?}" + ); + } + } + + fn run_git(dir: &Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); + } + + fn new_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + run_git(dir.path(), &["init", "-b", "main"]); + run_git(dir.path(), &["config", "user.email", "test@example.com"]); + run_git(dir.path(), &["config", "user.name", "Test"]); + dir + } + + #[test] + fn store_lifecycle_add_reply_resolve_reopen_delete() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + + let thread = store + .add_thread(AddThreadInput { + path: "web/src/App.tsx".into(), + line: 42, + end_line: 45, + side: "additions".into(), + body: "Check this".into(), + ..Default::default() + }) + .unwrap(); + assert!(!thread.id.is_empty()); + assert_eq!(thread.provider, "local"); + assert_eq!(thread.status, "open"); + assert_eq!(thread.branch, "main"); + assert_eq!((thread.line, thread.end_line), (42, 45)); + assert_eq!( + (thread.side.as_str(), thread.end_side.as_str()), + ("additions", "additions") + ); + assert_eq!(thread.comments.len(), 1); + assert_eq!(thread.comments[0].body, "Check this"); + assert_eq!(thread.comments[0].author, "Test"); // from repo user.name + + let thread = store + .add_reply( + &thread.id, + AddReplyInput { + body: "Reply".into(), + author: "agent".into(), + }, + ) + .unwrap(); + assert_eq!(thread.comments.len(), 2); + assert_eq!(thread.comments[1].body, "Reply"); + assert_eq!(thread.comments[1].author, "agent"); + + assert_eq!(store.resolve(&thread.id).unwrap().status, "resolved"); + assert_eq!(store.reopen(&thread.id).unwrap().status, "open"); + + store.delete(&thread.id).unwrap(); + assert!(store.list().unwrap().is_empty()); + } + + #[test] + fn store_lists_current_branch_only() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + store + .add_thread(AddThreadInput { + path: "a.go".into(), + line: 1, + body: "main".into(), + ..Default::default() + }) + .unwrap(); + + run_git(dir.path(), &["checkout", "-b", "feature/comments"]); + store + .add_thread(AddThreadInput { + path: "b.go".into(), + line: 1, + body: "feature".into(), + ..Default::default() + }) + .unwrap(); + + let threads = store.list().unwrap(); + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].path, "b.go"); + } + + #[test] + fn store_returns_not_found_for_other_branch() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + let thread = store + .add_thread(AddThreadInput { + path: "a.go".into(), + line: 1, + body: "main".into(), + ..Default::default() + }) + .unwrap(); + + run_git(dir.path(), &["checkout", "-b", "feature/comments"]); + let err = store.resolve(&thread.id).unwrap_err(); + assert!(matches!(err, CommentError::NotFound)); + } + + #[test] + fn store_keeps_concurrent_adds() { + let dir = new_repo(); + let store = std::sync::Arc::new(Store::new(dir.path()).unwrap()); + const COUNT: usize = 20; + std::thread::scope(|scope| { + for i in 0..COUNT { + let store = store.clone(); + scope.spawn(move || { + store + .add_thread(AddThreadInput { + path: format!("file-{i:02}.go"), + line: 1, + body: "body".into(), + ..Default::default() + }) + .unwrap(); + }); + } + }); + assert_eq!(store.list().unwrap().len(), COUNT); + } + + #[test] + fn add_thread_uses_explicit_author_over_git_config() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + let thread = store + .add_thread(AddThreadInput { + path: "a.go".into(), + line: 1, + body: "b".into(), + author: " carol ".into(), + ..Default::default() + }) + .unwrap(); + assert_eq!(thread.comments[0].author, "carol"); + } + + #[test] + fn thread_timestamps_round_trip_through_disk() { + let dir = new_repo(); + let store = Store::new(dir.path()).unwrap(); + let created = store + .add_thread(AddThreadInput { + path: "a.go".into(), + line: 1, + body: "b".into(), + ..Default::default() + }) + .unwrap(); + + // Re-open the store and reload from disk: timestamps must survive the + // JSON (RFC3339) serialize/parse cycle exactly. + let reloaded = Store::new(dir.path()).unwrap().list().unwrap(); + assert_eq!(reloaded.len(), 1); + assert_eq!(reloaded[0].created_at, created.created_at); + assert_eq!(reloaded[0].updated_at, created.updated_at); + assert_eq!(reloaded[0].id, created.id); + + // And a struct -> JSON -> struct round-trip is lossless. + let json = serde_json::to_string(&created).unwrap(); + let back: Thread = serde_json::from_str(&json).unwrap(); + assert_eq!(back.created_at, created.created_at); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b7ee54c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use std::{fs, path::PathBuf}; + +#[derive(Debug, Default, Deserialize)] +pub struct Config { + #[serde(default)] + pub ui: UiConfig, +} + +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct UiConfig { + #[serde(default)] + pub color_scheme: String, + #[serde(default)] + pub diff_theme: String, + #[serde(default)] + pub diff_style: String, + #[serde(default)] + pub ui_font_family: String, + #[serde(default)] + pub code_font_family: String, + pub word_wrap: Option, + pub line_numbers: Option, + pub line_backgrounds: Option, +} + +pub fn default_path() -> anyhow::Result { + let home = std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is not set"))?; + Ok(PathBuf::from(home) + .join(".config") + .join("diffs") + .join("config.toml")) +} + +pub fn load_default() -> anyhow::Result { + load(default_path()?) +} + +pub fn load(path: impl Into) -> anyhow::Result { + let path = path.into(); + match fs::read_to_string(&path) { + Ok(data) => Ok(toml::from_str(&data)?), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()), + Err(err) => Err(err.into()), + } +} + +pub fn normalize_ui(mut ui: UiConfig) -> UiConfig { + ui.color_scheme = ui.color_scheme.trim().to_string(); + ui.diff_theme = ui.diff_theme.trim().to_string(); + ui.diff_style = ui.diff_style.trim().to_string(); + ui.ui_font_family = ui.ui_font_family.trim().to_string(); + ui.code_font_family = ui.code_font_family.trim().to_string(); + ui +} + +pub fn is_color_scheme(value: &str) -> bool { + matches!(value, "dark" | "light" | "system") +} + +pub fn is_diff_style(value: &str) -> bool { + matches!(value, "split" | "unified") +} + +pub fn is_diff_theme(value: &str) -> bool { + matches!( + value, + "pierre" + | "github" + | "dark-plus" + | "light-plus" + | "one-dark-pro" + | "one-light" + | "monokai" + | "night-owl" + | "tokyo-night" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn load_missing_config_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let cfg = load(dir.path().join("missing.toml")).unwrap(); + assert!(cfg.ui.color_scheme.is_empty()); + assert!(cfg.ui.word_wrap.is_none()); + } + + #[test] + fn load_full_config() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all( + br#" +[ui] +color_scheme = "dark" +diff_theme = "github" +diff_style = "unified" +ui_font_family = '"Inter Variable", system-ui, sans-serif' +code_font_family = '"JetBrains Mono", ui-monospace, monospace' +word_wrap = true +line_numbers = false +line_backgrounds = true +"#, + ) + .unwrap(); + + let cfg = load(&path).unwrap(); + assert_eq!(cfg.ui.color_scheme, "dark"); + assert_eq!(cfg.ui.diff_theme, "github"); + assert_eq!(cfg.ui.diff_style, "unified"); + assert_eq!( + cfg.ui.ui_font_family, + r#""Inter Variable", system-ui, sans-serif"# + ); + assert_eq!( + cfg.ui.code_font_family, + r#""JetBrains Mono", ui-monospace, monospace"# + ); + assert_eq!(cfg.ui.word_wrap, Some(true)); + assert_eq!(cfg.ui.line_numbers, Some(false)); + assert_eq!(cfg.ui.line_backgrounds, Some(true)); + } + + #[test] + fn load_invalid_config_errors() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[ui\n").unwrap(); + assert!(load(&path).is_err()); + } + + #[test] + fn normalize_ui_trims_strings() { + let got = normalize_ui(UiConfig { + color_scheme: " dark ".to_string(), + diff_theme: " github ".to_string(), + diff_style: " unified ".to_string(), + ui_font_family: " ui-sans-serif ".to_string(), + code_font_family: " ui-monospace ".to_string(), + ..Default::default() + }); + assert_eq!(got.color_scheme, "dark"); + assert_eq!(got.diff_theme, "github"); + assert_eq!(got.diff_style, "unified"); + assert_eq!(got.ui_font_family, "ui-sans-serif"); + assert_eq!(got.code_font_family, "ui-monospace"); + } + + #[test] + fn ui_option_validation() { + assert!(is_color_scheme("system")); + assert!(!is_color_scheme("auto")); + assert!(is_diff_theme("pierre")); + assert!(!is_diff_theme("missing")); + assert!(is_diff_style("split")); + assert!(!is_diff_style("side-by-side")); + } +} diff --git a/src/gh.rs b/src/gh.rs new file mode 100644 index 0000000..8102579 --- /dev/null +++ b/src/gh.rs @@ -0,0 +1,931 @@ +use crate::{comments, git}; +use anyhow::{Context, bail}; +use serde::{Deserialize, Serialize}; +use std::{path::Path, time::Duration}; +use tokio::process::Command; +use url::Url; + +pub const DEFAULT_GITHUB_HOST: &str = "github.com"; +pub const DEFAULT_GH_TIMEOUT: Duration = Duration::from_secs(10); +const GH_PATCH_TIMEOUT: Duration = Duration::from_secs(90); +const GH_COMMENTS_TIMEOUT: Duration = Duration::from_secs(30); +const GITHUB_DIFF_MEDIA: &str = "application/vnd.github.v3.diff"; + +#[derive(Debug, Clone)] +pub struct PrTarget { + pub path: String, + pub host: String, +} + +#[derive(Debug, Clone)] +pub struct RemoteRepo { + pub host: String, + pub owner: String, + pub name: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestInfo { + pub title: String, + pub state: String, + pub draft: bool, + pub merged: bool, + pub author: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub additions: i64, + pub deletions: i64, + pub changed_files: i64, + pub commits: i64, + pub head_ref: String, + pub head_label: String, + pub head_repo: String, + pub base_ref: String, + pub base_label: String, + pub base_repo: String, +} + +#[derive(Debug, Deserialize)] +struct PullApiResponse { + title: String, + state: String, + draft: bool, + merged: bool, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + additions: i64, + deletions: i64, + changed_files: i64, + commits: i64, + user: Option, + head: PullRef, + base: PullRef, +} + +#[derive(Debug, Deserialize)] +struct PullRef { + #[serde(rename = "ref")] + ref_name: String, + label: String, + repo: Option, + #[serde(default)] + sha: String, +} + +#[derive(Debug, Deserialize)] +struct RepoName { + full_name: String, +} + +#[derive(Debug, Deserialize)] +struct Author { + login: String, +} + +pub async fn run(dir: impl AsRef, args: &[&str]) -> anyhow::Result { + let output = tokio::time::timeout( + DEFAULT_GH_TIMEOUT, + Command::new("gh").args(args).current_dir(dir).output(), + ) + .await + .context("gh timed out")??; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + bail!("gh failed"); + } + bail!("{stderr}"); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +pub async fn run_bytes(label: &str, args: &[String], timeout: Duration) -> anyhow::Result> { + let output = tokio::time::timeout(timeout, Command::new("gh").args(args).output()) + .await + .with_context(|| format!("{label} timed out"))??; + if output.status.success() { + return Ok(output.stdout); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + bail!("{label} failed"); + } + bail!("{label} failed: {stderr}"); +} + +pub async fn current_branch_pr_url(dir: impl AsRef) -> anyhow::Result { + let url = run(dir, &["pr", "view", "--json", "url", "-q", ".url"]) + .await + .context("resolve PR for current branch")?; + if url.is_empty() { + bail!("no pull request found for the current branch"); + } + Ok(url) +} + +pub async fn pr_target_from_args( + args: &[String], + dir: impl AsRef, +) -> anyhow::Result { + if args.is_empty() { + let url = current_branch_pr_url(dir).await?; + return parse_pr_target(&url); + } + if let Some(number) = pr_number(args) { + let remote = git::remote_url(dir, "origin") + .with_context(|| format!("resolve current repository for PR #{number}"))?; + let repo = repo_from_remote_url(&remote) + .with_context(|| format!("resolve current repository for PR #{number}"))?; + return Ok(PrTarget { + path: format!("/{}/{}/pull/{number}", repo.owner, repo.name), + host: repo.host, + }); + } + parse_pr_target(&args[0]) +} + +pub fn parse_pr_target(target: &str) -> anyhow::Result { + let mut target = target.trim().to_string(); + if target.is_empty() { + bail!("expected one GitHub PR target"); + } + let mut host = String::new(); + let lower = target.to_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + let url = Url::parse(&target)?; + host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("target URL must include a host"))? + .to_lowercase(); + target = url.path().to_string(); + } + if !target.starts_with('/') { + target = format!("/{target}"); + } + let parts: Vec<&str> = target.trim_matches('/').split('/').collect(); + if parts.len() >= 4 + && parts[2] == "pull" + && !parts[3].is_empty() + && (parts.len() == 4 || is_pull_request_subpage(&parts[4..])) + { + return Ok(PrTarget { + path: format!("/{}/{}/pull/{}", parts[0], parts[1], parts[3]), + host, + }); + } + bail!("target must be a GitHub PR URL or /org/repo/pull/123") +} + +fn pr_number(args: &[String]) -> Option { + if args.len() != 1 { + return None; + } + let value = args[0].trim(); + value + .parse::() + .ok() + .filter(|number| *number > 0) + .map(|_| value.to_string()) +} + +pub fn repo_from_remote_url(remote: &str) -> anyhow::Result { + let remote = remote.trim(); + if remote.is_empty() { + bail!("origin remote URL is empty"); + } + let (host, path) = if remote.contains("://") { + let url = Url::parse(remote)?; + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("origin remote URL must include a host"))? + .to_lowercase(); + (host, url.path().to_string()) + } else { + let (user_host, path) = remote.split_once(':').ok_or_else(|| { + anyhow::anyhow!("origin remote URL must be an absolute URL or SCP-style remote") + })?; + if user_host.contains('/') { + bail!("origin remote URL must be an absolute URL or SCP-style remote"); + } + let host = user_host + .split_once('@') + .map(|(_, host)| host) + .unwrap_or(user_host) + .to_lowercase(); + (host, path.to_string()) + }; + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + if parts.len() < 2 { + bail!("origin remote URL must include owner and repository"); + } + let name = parts[1].trim_end_matches(".git"); + if parts[0].is_empty() || name.is_empty() { + bail!("origin remote URL must include owner and repository"); + } + Ok(RemoteRepo { + host, + owner: parts[0].to_string(), + name: name.to_string(), + }) +} + +fn is_pull_request_subpage(parts: &[&str]) -> bool { + matches!(parts, ["checks" | "commits" | "files" | "reviews"]) +} + +pub async fn pull_request_patch( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result { + let args = vec![ + "api".to_string(), + format!("repos/{org}/{repo}/pulls/{number}"), + "--hostname".to_string(), + github_host.to_string(), + "-H".to_string(), + format!("Accept: {GITHUB_DIFF_MEDIA}"), + ]; + Ok(String::from_utf8( + run_bytes("gh api", &args, GH_PATCH_TIMEOUT).await?, + )?) +} + +async fn fetch_pull( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result { + let args = vec![ + "api".to_string(), + format!("repos/{org}/{repo}/pulls/{number}"), + "--hostname".to_string(), + github_host.to_string(), + ]; + Ok(serde_json::from_slice( + &run_bytes("gh api pull request", &args, GH_COMMENTS_TIMEOUT).await?, + )?) +} + +async fn pull_request_head_sha( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result { + let sha = fetch_pull(github_host, org, repo, number).await?.head.sha; + if sha.is_empty() { + bail!("pull request head sha is missing"); + } + Ok(sha) +} + +pub async fn pull_request_info( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result { + let response = fetch_pull(github_host, org, repo, number).await?; + Ok(PullRequestInfo { + title: response.title, + state: response.state, + draft: response.draft, + merged: response.merged, + author: response.user.map(|user| user.login).unwrap_or_default(), + created_at: response.created_at, + updated_at: response.updated_at, + additions: response.additions, + deletions: response.deletions, + changed_files: response.changed_files, + commits: response.commits, + head_ref: response.head.ref_name, + head_label: response.head.label, + head_repo: response + .head + .repo + .map(|repo| repo.full_name) + .unwrap_or_default(), + base_ref: response.base.ref_name, + base_label: response.base.label, + base_repo: response + .base + .repo + .map(|repo| repo.full_name) + .unwrap_or_default(), + }) +} + +fn github_side(side: &str) -> &str { + match side { + "deletions" => "LEFT", + _ => "RIGHT", + } +} + +fn comment_side(side: &str) -> &str { + match side { + "RIGHT" => "additions", + "LEFT" => "deletions", + _ => "", + } +} + +// --- GitHub review-thread CRUD --- + +// GitHub's GraphQL API returns `null` for fields like `line`, `path`, and +// `endCursor` (e.g. outdated or file-level threads). serde errors unless we +// coalesce those nulls here. +fn null_to_default<'de, D, T>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::deserialize(deserializer)?.unwrap_or_default()) +} + +#[derive(Debug, Deserialize)] +struct ReviewThreadsResponse { + #[serde(default)] + data: ReviewThreadsData, +} + +#[derive(Debug, Default, Deserialize)] +struct ReviewThreadsData { + #[serde(default)] + repository: RepositoryNode, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RepositoryNode { + #[serde(default)] + pull_request: PullRequestNode, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PullRequestNode { + #[serde(default)] + review_threads: ReviewThreadsConn, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReviewThreadsConn { + #[serde(default)] + nodes: Vec, + #[serde(default)] + page_info: PageInfo, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageInfo { + #[serde(default)] + has_next_page: bool, + #[serde(default, deserialize_with = "null_to_default")] + end_cursor: String, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReviewThread { + #[serde(default, deserialize_with = "null_to_default")] + id: String, + #[serde(default)] + is_resolved: bool, + #[serde(default, deserialize_with = "null_to_default")] + path: String, + #[serde(default, deserialize_with = "null_to_default")] + line: i64, + #[serde(default, deserialize_with = "null_to_default")] + diff_side: String, + #[serde(default, deserialize_with = "null_to_default")] + start_line: i64, + #[serde(default, deserialize_with = "null_to_default")] + start_diff_side: String, + #[serde(default)] + comments: ReviewCommentsConn, +} + +#[derive(Debug, Default, Deserialize)] +struct ReviewCommentsConn { + #[serde(default)] + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReviewComment { + #[serde(default, deserialize_with = "null_to_default")] + id: String, + #[serde(default, deserialize_with = "null_to_default")] + database_id: i64, + author: Option, + #[serde(default, deserialize_with = "null_to_default")] + body: String, + #[serde(default, deserialize_with = "null_to_default")] + url: String, + created_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +struct CreatedComment { + #[serde(default)] + id: i64, + #[serde(default)] + node_id: String, +} + +pub async fn list_pull_request_comments( + github_host: &str, + org: &str, + repo: &str, + number: &str, +) -> anyhow::Result> { + let mut threads = Vec::new(); + let mut cursor = String::new(); + loop { + let mut args = vec![ + "api".to_string(), + "graphql".to_string(), + "--hostname".to_string(), + github_host.to_string(), + "-f".to_string(), + format!("query={REVIEW_THREADS_QUERY}"), + "-F".to_string(), + format!("owner={org}"), + "-F".to_string(), + format!("name={repo}"), + "-F".to_string(), + format!("number={number}"), + ]; + if !cursor.is_empty() { + args.push("-F".to_string()); + args.push(format!("cursor={cursor}")); + } + let out = run_bytes("gh api graphql", &args, GH_COMMENTS_TIMEOUT).await?; + let response: ReviewThreadsResponse = serde_json::from_slice(&out)?; + let page = response.data.repository.pull_request.review_threads; + for thread in page.nodes { + if let Some(converted) = convert_github_thread(thread) { + threads.push(converted); + } + } + if !page.page_info.has_next_page || page.page_info.end_cursor.is_empty() { + return Ok(threads); + } + cursor = page.page_info.end_cursor; + } +} + +async fn find_pull_request_thread( + github_host: &str, + org: &str, + repo: &str, + number: &str, + matches: impl Fn(&comments::Thread) -> bool, +) -> anyhow::Result { + let threads = list_pull_request_comments(github_host, org, repo, number).await?; + threads + .into_iter() + .find(|thread| matches(thread)) + .ok_or_else(|| anyhow::anyhow!("comment thread not found")) +} + +pub async fn add_pull_request_comment( + github_host: &str, + org: &str, + repo: &str, + number: &str, + input: comments::AddThreadInput, +) -> anyhow::Result { + let clean = comments::clean_thread_input(input)?; + let sha = pull_request_head_sha(github_host, org, repo, number).await?; + + let mut args = vec![ + "api".to_string(), + "-X".to_string(), + "POST".to_string(), + format!("repos/{org}/{repo}/pulls/{number}/comments"), + "--hostname".to_string(), + github_host.to_string(), + "--raw-field".to_string(), + format!("body={}", clean.body), + "--raw-field".to_string(), + format!("commit_id={sha}"), + "--raw-field".to_string(), + format!("path={}", clean.path), + "--raw-field".to_string(), + format!("side={}", github_side(&clean.end_side)), + "--field".to_string(), + format!("line={}", clean.end_line), + ]; + if clean.end_line != clean.line || clean.end_side != clean.side { + args.push("--field".to_string()); + args.push(format!("start_line={}", clean.line)); + args.push("--raw-field".to_string()); + args.push(format!("start_side={}", github_side(&clean.side))); + } + let out = run_bytes( + "gh api create pull request comment", + &args, + GH_COMMENTS_TIMEOUT, + ) + .await?; + let created: CreatedComment = serde_json::from_slice(&out)?; + let created_db = created.id.to_string(); + find_pull_request_thread(github_host, org, repo, number, |thread| { + thread.comments.iter().any(|comment| { + comment.id == created.node_id || (created.id != 0 && comment.id == created_db) + }) + }) + .await +} + +pub async fn add_pull_request_reply( + github_host: &str, + org: &str, + repo: &str, + number: &str, + thread_id: &str, + input: comments::AddReplyInput, +) -> anyhow::Result { + let body = input.body.trim().to_string(); + if body.is_empty() { + bail!("body is required"); + } + let thread = + find_pull_request_thread(github_host, org, repo, number, |t| t.id == thread_id).await?; + let reply_to = thread + .reply_to_id + .filter(|id| *id != 0) + .ok_or_else(|| anyhow::anyhow!("pull request thread has no reply target"))?; + + let args = vec![ + "api".to_string(), + "-X".to_string(), + "POST".to_string(), + format!("repos/{org}/{repo}/pulls/{number}/comments/{reply_to}/replies"), + "--hostname".to_string(), + github_host.to_string(), + "--raw-field".to_string(), + format!("body={body}"), + ]; + run_bytes( + "gh api create pull request comment reply", + &args, + GH_COMMENTS_TIMEOUT, + ) + .await?; + find_pull_request_thread(github_host, org, repo, number, |t| t.id == thread_id).await +} + +pub async fn set_pull_request_thread_resolved( + github_host: &str, + org: &str, + repo: &str, + number: &str, + thread_id: &str, + resolved: bool, +) -> anyhow::Result { + let (mutation, label) = if resolved { + ( + RESOLVE_REVIEW_THREAD_MUTATION, + "gh api resolve review thread", + ) + } else { + ( + UNRESOLVE_REVIEW_THREAD_MUTATION, + "gh api unresolve review thread", + ) + }; + let args = vec![ + "api".to_string(), + "graphql".to_string(), + "--hostname".to_string(), + github_host.to_string(), + "-f".to_string(), + format!("query={mutation}"), + "-F".to_string(), + format!("threadID={thread_id}"), + ]; + run_bytes(label, &args, GH_COMMENTS_TIMEOUT).await?; + find_pull_request_thread(github_host, org, repo, number, |t| t.id == thread_id).await +} + +fn convert_github_thread(thread: ReviewThread) -> Option { + if thread.id.is_empty() || thread.comments.nodes.is_empty() { + return None; + } + let first = &thread.comments.nodes[0]; + let last = &thread.comments.nodes[thread.comments.nodes.len() - 1]; + + let mut line = thread.line; + if thread.start_line > 0 { + line = thread.start_line; + } + if thread.path.is_empty() || line < 1 { + return None; + } + + let mut side = comment_side(&thread.start_diff_side); + if side.is_empty() { + side = comment_side(&thread.diff_side); + } + if side.is_empty() { + side = comments::DEFAULT_SIDE; + } + + let mut end_line = thread.line; + if end_line == 0 { + end_line = line; + } + let mut end_side = comment_side(&thread.diff_side); + if end_side.is_empty() { + end_side = side; + } + + let status = if thread.is_resolved { + "resolved" + } else { + "open" + }; + let reply_to_id = (first.database_id != 0).then_some(first.database_id); + + let mut converted = comments::Thread { + id: thread.id, + provider: "github".to_string(), + branch: String::new(), + path: thread.path, + side: side.to_string(), + line: line as u32, + end_side: String::new(), + end_line: 0, + status: status.to_string(), + created_at: first.created_at, + updated_at: last.created_at, + comments: thread + .comments + .nodes + .iter() + .map(|comment| comments::Comment { + id: comment_id(comment), + author: comment_author(comment), + body: comment.body.clone(), + created_at: comment.created_at, + }) + .collect(), + reply_to_id, + url: first.url.clone(), + }; + if end_line != line || end_side != side { + converted.end_side = end_side.to_string(); + converted.end_line = end_line as u32; + } + Some(converted) +} + +fn comment_id(comment: &ReviewComment) -> String { + if !comment.id.is_empty() { + return comment.id.clone(); + } + if comment.database_id != 0 { + return comment.database_id.to_string(); + } + String::new() +} + +fn comment_author(comment: &ReviewComment) -> String { + match &comment.author { + Some(author) if !author.login.is_empty() => author.login.clone(), + _ => "github".to_string(), + } +} + +const REVIEW_THREADS_QUERY: &str = r#" +query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + path + line + diffSide + startLine + startDiffSide + comments(first: 100) { + nodes { + id + databaseId + author { + login + } + body + url + createdAt + } + } + } + } + } + } +}"#; + +const RESOLVE_REVIEW_THREAD_MUTATION: &str = r#" +mutation($threadID: ID!) { + resolveReviewThread(input: {threadId: $threadID}) { + thread { + id + isResolved + } + } +}"#; + +const UNRESOLVE_REVIEW_THREAD_MUTATION: &str = r#" +mutation($threadID: ID!) { + unresolveReviewThread(input: {threadId: $threadID}) { + thread { + id + isResolved + } + } +}"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pr_target_paths_and_hosts() { + // (input, want_path, want_host) + let cases = [ + ("/org/repo/pull/123", "/org/repo/pull/123", ""), + ("org/repo/pull/123", "/org/repo/pull/123", ""), + ( + "https://github.com/org/repo/pull/123", + "/org/repo/pull/123", + "github.com", + ), + ( + "https://github.example.com:8443/org/repo/pull/123", + "/org/repo/pull/123", + "github.example.com", + ), + ( + "https://GITHUB.example.com/org/repo/pull/123", + "/org/repo/pull/123", + "github.example.com", + ), + ( + "HTTPS://github.example.com/org/repo/pull/123", + "/org/repo/pull/123", + "github.example.com", + ), + ( + "https://github.example.com/org/repo/pull/123/files", + "/org/repo/pull/123", + "github.example.com", + ), + ( + "https://github.example.com/org/repo/pull/123/commits", + "/org/repo/pull/123", + "github.example.com", + ), + ]; + for (input, want_path, want_host) in cases { + let target = parse_pr_target(input).unwrap_or_else(|e| panic!("{input}: {e}")); + assert_eq!(target.path, want_path, "path for {input}"); + assert_eq!(target.host, want_host, "host for {input}"); + } + } + + #[test] + fn parse_pr_target_rejects_non_pr() { + for input in [ + "", + "org/repo", + "https://github.com/org/repo", + "/org/repo/pull/", + ] { + assert!( + parse_pr_target(input).is_err(), + "expected error for {input:?}" + ); + } + } + + #[test] + fn repo_from_remote_url_variants() { + let https = repo_from_remote_url("https://github.com/org/repo.git").unwrap(); + assert_eq!( + ( + https.host.as_str(), + https.owner.as_str(), + https.name.as_str() + ), + ("github.com", "org", "repo") + ); + + let scp = repo_from_remote_url("git@github.com:org/repo.git").unwrap(); + assert_eq!( + (scp.host.as_str(), scp.owner.as_str(), scp.name.as_str()), + ("github.com", "org", "repo") + ); + + let ssh = repo_from_remote_url("ssh://git@github.example.com/org/repo.git").unwrap(); + assert_eq!( + (ssh.host.as_str(), ssh.owner.as_str(), ssh.name.as_str()), + ("github.example.com", "org", "repo") + ); + + assert!(repo_from_remote_url("https://github.com/org").is_err()); + assert!(repo_from_remote_url("").is_err()); + } + + #[test] + fn pr_number_only_for_single_positive_integer() { + assert_eq!(pr_number(&["123".to_string()]), Some("123".to_string())); + assert_eq!(pr_number(&["0".to_string()]), None); + assert_eq!(pr_number(&["abc".to_string()]), None); + assert_eq!(pr_number(&["1".to_string(), "2".to_string()]), None); + assert_eq!(pr_number(&[]), None); + } + + #[test] + fn side_mappings_round_trip() { + assert_eq!(github_side("deletions"), "LEFT"); + assert_eq!(github_side("additions"), "RIGHT"); + assert_eq!(github_side("anything"), "RIGHT"); + assert_eq!(comment_side("LEFT"), "deletions"); + assert_eq!(comment_side("RIGHT"), "additions"); + assert_eq!(comment_side("?"), ""); + } + + #[test] + fn convert_github_thread_maps_fields_and_range() { + let created: chrono::DateTime = "2026-05-23T12:00:00Z".parse().unwrap(); + let updated: chrono::DateTime = "2026-05-23T13:00:00Z".parse().unwrap(); + let thread = ReviewThread { + id: "thr1".to_string(), + is_resolved: true, + path: "src/app.rs".to_string(), + line: 10, + diff_side: "RIGHT".to_string(), + start_line: 8, + start_diff_side: "RIGHT".to_string(), + comments: ReviewCommentsConn { + nodes: vec![ + ReviewComment { + id: "c1".to_string(), + database_id: 42, + author: Some(Author { + login: "alice".to_string(), + }), + body: "first".to_string(), + url: "http://example/c1".to_string(), + created_at: created, + }, + ReviewComment { + id: "c2".to_string(), + database_id: 43, + author: None, + body: "second".to_string(), + url: "http://example/c2".to_string(), + created_at: updated, + }, + ], + }, + }; + let converted = convert_github_thread(thread).expect("thread converts"); + assert_eq!(converted.id, "thr1"); + assert_eq!(converted.provider, "github"); + assert_eq!(converted.status, "resolved"); + assert_eq!(converted.line, 8); // start_line wins + assert_eq!(converted.end_line, 10); // thread.line + assert_eq!(converted.side, "additions"); + assert_eq!(converted.end_side, "additions"); + assert_eq!(converted.reply_to_id, Some(42)); + assert_eq!(converted.url, "http://example/c1"); + assert_eq!(converted.created_at, created); + assert_eq!(converted.updated_at, updated); + assert_eq!(converted.comments.len(), 2); + assert_eq!(converted.comments[0].author, "alice"); + assert_eq!(converted.comments[1].author, "github"); // fallback + } + + #[test] + fn convert_github_thread_skips_empty() { + assert!(convert_github_thread(ReviewThread::default()).is_none()); + } +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..39c0bdc --- /dev/null +++ b/src/git.rs @@ -0,0 +1,415 @@ +use git2::{ + BranchType, Diff, DiffFindOptions, DiffFormat, DiffOptions, ErrorCode, ObjectType, Oid, + Repository, Status, StatusOptions, +}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum GitError { + #[error("not a git repository")] + NotRepository, + #[error(transparent)] + Git(#[from] git2::Error), + #[error("repository has no working tree")] + NoWorkdir, + #[error("invalid utf-8 path in repository")] + InvalidPath, +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ChangeAction { + Added, + Modified, + Deleted, + Renamed, +} + +impl ChangeAction { + pub fn as_str(self) -> &'static str { + match self { + ChangeAction::Added => "added", + ChangeAction::Modified => "modified", + ChangeAction::Deleted => "deleted", + ChangeAction::Renamed => "renamed", + } + } +} + +/// Maps a git2 status to a `ChangeAction`: deletion, then rename/copy, then +/// addition, otherwise modification. +fn status_action(status: Status) -> ChangeAction { + if status.intersects(Status::INDEX_DELETED | Status::WT_DELETED) { + ChangeAction::Deleted + } else if status.intersects(Status::INDEX_RENAMED | Status::WT_RENAMED) { + ChangeAction::Renamed + } else if status.intersects(Status::INDEX_NEW | Status::WT_NEW) { + ChangeAction::Added + } else { + ChangeAction::Modified + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangedFile { + pub path: String, + pub action: ChangeAction, +} + +pub fn discover(cwd: impl AsRef) -> Result { + Repository::discover(cwd).map_err(|err| { + if err.code() == ErrorCode::NotFound { + GitError::NotRepository + } else { + GitError::Git(err) + } + }) +} + +pub fn root(cwd: impl AsRef) -> Result { + let repo = discover(cwd)?; + repo.workdir() + .map(Path::to_path_buf) + .ok_or(GitError::NoWorkdir) +} + +pub fn branch(cwd: impl AsRef) -> String { + discover(cwd) + .ok() + .and_then(|repo| branch_for_repo(&repo).ok()) + .unwrap_or_default() +} + +pub fn branch_for_repo(repo: &Repository) -> Result { + match repo.head() { + Ok(head) => { + if let Some(name) = head.shorthand().filter(|name| !name.is_empty()) { + return Ok(name.to_string()); + } + // Detached HEAD: fall back to the short commit oid. + let commit = head.peel_to_commit()?; + Ok(commit.id().to_string().chars().take(7).collect()) + } + // Unborn branch (fresh repo, no commits yet): `git branch --show-current` + // still reports the branch HEAD points at, so read it from the symref. + Err(err) if err.code() == ErrorCode::UnbornBranch => { + head_branch_name(repo).ok_or(GitError::Git(err)) + } + Err(err) => Err(GitError::Git(err)), + } +} + +fn head_branch_name(repo: &Repository) -> Option { + let head = repo.find_reference("HEAD").ok()?; + let target = head.symbolic_target()?; + target + .strip_prefix("refs/heads/") + .map(|name| name.to_string()) +} + +pub fn ref_exists(cwd: impl AsRef, name: &str) -> bool { + discover(cwd) + .and_then(|repo| { + repo.revparse_single(name)? + .peel(ObjectType::Commit) + .map(|_| ()) + .map_err(GitError::from) + }) + .is_ok() +} + +pub fn resolve_local_ref(cwd: impl AsRef, name: &str) -> Option { + if ref_exists(&cwd, name) { + return Some(name.to_string()); + } + let candidate = format!("origin/{name}"); + ref_exists(cwd, &candidate).then_some(candidate) +} + +pub fn remote_url(cwd: impl AsRef, remote: &str) -> Result { + let repo = discover(cwd)?; + Ok(repo + .find_remote(remote)? + .url() + .unwrap_or_default() + .to_string()) +} + +pub fn config_string(cwd: impl AsRef, key: &str) -> Option { + discover(cwd) + .ok() + .and_then(|repo| repo.config().ok()) + .and_then(|cfg| cfg.get_string(key).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub fn has_head(repo: &Repository) -> bool { + repo.head() + .and_then(|head| head.peel_to_commit()) + .map(|_| ()) + .is_ok() +} + +pub fn local_diff(cwd: impl AsRef) -> Result { + let repo = discover(cwd)?; + if has_head(&repo) { + // Working tree (with index) vs HEAD, with untracked files inline. + let head_tree = repo.head()?.peel_to_tree()?; + let mut opts = diff_options(); + render(repo.diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut opts))?) + } else { + // No HEAD yet: staged (empty tree -> index) then unstaged (index -> workdir). + let index = repo.index()?; + let mut patch = String::new(); + let mut staged_opts = diff_options(); + append_diff( + &mut patch, + repo.diff_tree_to_index(None, Some(&index), Some(&mut staged_opts))?, + )?; + let mut workdir_opts = diff_options(); + append_diff( + &mut patch, + repo.diff_index_to_workdir(Some(&index), Some(&mut workdir_opts))?, + )?; + Ok(patch) + } +} + +pub fn branch_diff(cwd: impl AsRef, base: &str, include_dirty: bool) -> Result { + let repo = discover(cwd)?; + let head = repo.head()?.peel_to_commit()?; + let base_commit = repo.revparse_single(base)?.peel_to_commit()?; + let merge_base = repo.merge_base(base_commit.id(), head.id())?; + if include_dirty { + diff_oid_to_workdir(&repo, merge_base) + } else { + diff_oid_to_tree(&repo, merge_base, head.id()) + } +} + +fn diff_oid_to_tree(repo: &Repository, from: Oid, to: Oid) -> Result { + let from_tree = repo.find_commit(from)?.tree()?; + let to_tree = repo.find_commit(to)?.tree()?; + let mut opts = diff_options(); + render(repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut opts))?) +} + +fn diff_oid_to_workdir(repo: &Repository, from: Oid) -> Result { + let tree = repo.find_commit(from)?.tree()?; + let mut opts = diff_options(); + render(repo.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))?) +} + +/// Renders a prepared diff (with rename detection) to unified-diff text. +fn render(diff: Diff<'_>) -> Result { + let mut patch = String::new(); + append_diff(&mut patch, diff)?; + Ok(patch) +} + +fn diff_options() -> DiffOptions { + let mut opts = DiffOptions::new(); + opts.include_untracked(true) + .show_untracked_content(true) + .recurse_untracked_dirs(true) + .include_typechange(true) + .include_typechange_trees(true) + .ignore_submodules(false); + opts +} + +fn append_diff(output: &mut String, mut diff: Diff<'_>) -> Result<()> { + let mut find = DiffFindOptions::new(); + find.renames(true).copies(false); + let _ = diff.find_similar(Some(&mut find)); + + diff.print(DiffFormat::Patch, |_delta, _hunk, line| { + if matches!(line.origin(), ' ' | '+' | '-' | '\\') { + output.push(line.origin()); + } + if let Ok(text) = std::str::from_utf8(line.content()) { + output.push_str(text); + } else { + output.push_str(&String::from_utf8_lossy(line.content())); + } + true + })?; + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + Ok(()) +} + +fn status_options() -> StatusOptions { + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true); + opts +} + +fn status_entry_path(entry: &git2::StatusEntry<'_>) -> Option { + entry + .head_to_index() + .and_then(|d| d.new_file().path()) + .or_else(|| entry.index_to_workdir().and_then(|d| d.new_file().path())) + .or_else(|| entry.path().map(Path::new)) + .map(|path| path.to_string_lossy().replace('\\', "/")) +} + +pub fn changed_files(cwd: impl AsRef) -> Result> { + let repo = discover(cwd)?; + let statuses = repo.statuses(Some(&mut status_options()))?; + let mut files = Vec::new(); + for entry in statuses.iter() { + let path = status_entry_path(&entry).ok_or(GitError::InvalidPath)?; + files.push(ChangedFile { + path, + action: status_action(entry.status()), + }); + } + files.sort_by(|a, b| a.path.cmp(&b.path)); + files.dedup_by(|a, b| a.path == b.path); + Ok(files) +} + +/// Builds a map of repository-relative (forward-slash) path to change action. +/// Used by the watcher to label changed files for the reload logger. +pub fn status_map(repo: &Repository) -> Result> { + let statuses = repo.statuses(Some(&mut status_options()))?; + let mut map = BTreeMap::new(); + for entry in statuses.iter() { + if let Some(path) = status_entry_path(&entry) { + map.insert(path, status_action(entry.status())); + } + } + Ok(map) +} + +pub fn default_branch(cwd: impl AsRef) -> Option { + let repo = discover(cwd).ok()?; + for candidate in ["main", "master"] { + if repo.find_branch(candidate, BranchType::Local).is_ok() { + return Some(candidate.to_string()); + } + let origin = format!("origin/{candidate}"); + if ref_exists(repo.workdir()?, &origin) { + return Some(origin); + } + } + None +} + +/// Reports whether `path` is ignored by the repository's gitignore rules, using +/// libgit2's native matcher (the full hierarchy: nested `.gitignore`, +/// `.git/info/exclude`, and the global `core.excludesFile`). Paths outside the +/// working tree, or any lookup error, are treated as not ignored. +pub fn is_path_ignored(repo: &Repository, path: impl AsRef) -> bool { + let path = path.as_ref(); + let Some(workdir) = repo.workdir() else { + return false; + }; + let Some(relative) = workdir_relative_path(workdir, path) else { + return false; + }; + repo.is_path_ignored(Path::new(&relative)).unwrap_or(false) +} + +fn workdir_relative_path(workdir: &Path, path: &Path) -> Option { + if let Ok(relative) = path.strip_prefix(workdir) { + return Some(git_path(relative)); + } + + if let Ok(workdir) = workdir.canonicalize() + && let Ok(relative) = path.strip_prefix(workdir) + { + return Some(git_path(relative)); + } + + strip_git_path_prefix(&git_path(path), &git_path(workdir)) +} + +fn git_path(path: &Path) -> String { + let path = path.to_string_lossy().replace('\\', "/"); + path.strip_prefix("//?/").unwrap_or(&path).to_string() +} + +fn strip_git_path_prefix(path: &str, workdir: &str) -> Option { + let workdir = workdir.trim_end_matches('/'); + if path.eq_ignore_ascii_case(workdir) { + return Some(String::new()); + } + + let prefix = format!("{workdir}/"); + path.get(prefix.len()..) + .filter(|_| path[..prefix.len()].eq_ignore_ascii_case(&prefix)) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, process::Command}; + + fn git(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); + } + + #[test] + fn patch_lines_include_unified_diff_origin_prefixes() { + let repo = tempfile::tempdir().unwrap(); + git(repo.path(), &["init"]); + git(repo.path(), &["config", "user.email", "diffs@example.com"]); + git(repo.path(), &["config", "user.name", "Diffs Test"]); + fs::write(repo.path().join("tracked.txt"), "one\n").unwrap(); + git(repo.path(), &["add", "."]); + git(repo.path(), &["commit", "-m", "initial"]); + + fs::write(repo.path().join("tracked.txt"), "one\ntwo\n").unwrap(); + fs::write(repo.path().join("untracked.txt"), "new\n").unwrap(); + + let patch = local_diff(repo.path()).unwrap(); + assert!(patch.contains(" one\n"), "{patch}"); + assert!(patch.contains("+two\n"), "{patch}"); + assert!(patch.contains("+new\n"), "{patch}"); + } + + #[test] + fn is_path_ignored_honors_gitignore_hierarchy() { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init"]); + // Canonicalize like server::new does, so paths share libgit2's workdir + // prefix (macOS temp dirs are /var -> /private/var symlinks otherwise). + let root = dir.path().canonicalize().unwrap(); + fs::write(root.join(".gitignore"), "target/\n*.log\n").unwrap(); + fs::create_dir_all(root.join("target")).unwrap(); + fs::create_dir_all(root.join("nested")).unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("nested/.gitignore"), "secret.txt\n").unwrap(); + fs::write(root.join("target/app"), "").unwrap(); + fs::write(root.join("debug.log"), "").unwrap(); + fs::write(root.join("nested/secret.txt"), "").unwrap(); + fs::write(root.join("src/main.rs"), "").unwrap(); + let repo = discover(&root).unwrap(); + + assert!(is_path_ignored(&repo, root.join("target/app"))); + assert!(is_path_ignored(&repo, root.join("debug.log"))); + assert!(is_path_ignored(&repo, root.join("nested/secret.txt"))); + assert!(!is_path_ignored(&repo, root.join("src/main.rs"))); + // Paths outside the working tree must not be treated as ignored. + assert!(!is_path_ignored(&repo, "/etc/hosts")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..381bea8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod cli; +pub mod comments; +pub mod config; +pub mod gh; +pub mod git; +pub mod server; +pub mod webassets; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..369064b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,10 @@ +#[tokio::main] +async fn main() { + let started = std::time::Instant::now(); + if let Err(err) = diffs::cli::run(started).await { + if err.downcast_ref::().is_none() { + eprintln!("{err}"); + } + std::process::exit(1); + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..714431b --- /dev/null +++ b/src/server.rs @@ -0,0 +1,1037 @@ +use crate::{ + comments::{self, AddReplyInput, AddThreadInput, CommentError, Store, Thread}, + config::{self, UiConfig}, + gh, git, + webassets::Assets, +}; +use axum::{ + Json, Router, + body::Body, + extract::{Path, Query, State}, + http::{StatusCode, header}, + response::{ + IntoResponse, Response, Sse, + sse::{Event, KeepAlive}, + }, + routing::{delete, get, post}, +}; +use notify::{RecursiveMode, Watcher}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::{ + collections::{BTreeMap, BTreeSet}, + net::SocketAddr, + panic::AssertUnwindSafe, + path::{Path as FsPath, PathBuf}, + sync::Arc, + time::Duration, +}; +use tokio::sync::broadcast; +use tokio_stream::{StreamExt, wrappers::BroadcastStream}; + +/// Callback invoked on each debounced reload while watching, with the files +/// that changed in that batch (empty for a git-state-only change, e.g. a branch +/// switch). Used by the CLI's reload logger. +pub type OnChange = Arc) + Send + Sync>; + +#[derive(Clone)] +pub struct ServerConfig { + pub cwd: PathBuf, + pub github_host: String, + pub ui: UiConfig, + pub watch: bool, + pub on_change: Option, +} + +#[derive(Clone)] +struct AppState { + cwd: PathBuf, + github_host: String, + ui: UiConfig, + comments: Option>, + events: broadcast::Sender<()>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigResponse { + cwd: String, + git_branch: String, + github_host: String, + #[serde(skip_serializing_if = "String::is_empty")] + color_scheme: String, + #[serde(skip_serializing_if = "String::is_empty")] + diff_theme: String, + #[serde(skip_serializing_if = "String::is_empty")] + diff_style: String, + #[serde(skip_serializing_if = "String::is_empty")] + ui_font_family: String, + #[serde(skip_serializing_if = "String::is_empty")] + code_font_family: String, + #[serde(skip_serializing_if = "Option::is_none")] + word_wrap: Option, + #[serde(skip_serializing_if = "Option::is_none")] + line_numbers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + line_backgrounds: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RepoContextResponse { + #[serde(skip_serializing_if = "String::is_empty")] + repo_url: String, + #[serde(skip_serializing_if = "String::is_empty")] + pr_url: String, + #[serde(skip_serializing_if = "String::is_empty")] + branch_base: String, +} + +#[derive(Debug, Deserialize)] +struct BranchDiffQuery { + base: Option, + dirty: Option, +} + +#[derive(Debug, Deserialize)] +struct CommentTargetQuery { + org: Option, + repo: Option, + number: Option, +} + +pub struct RunningServer { + pub router: Router, + _watcher: Option, +} + +pub fn new(cfg: ServerConfig) -> anyhow::Result { + let cwd = std::fs::canonicalize(if cfg.cwd.as_os_str().is_empty() { + PathBuf::from(".") + } else { + cfg.cwd + })?; + let github_host = if cfg.github_host.trim().is_empty() { + gh::DEFAULT_GITHUB_HOST.to_string() + } else { + cfg.github_host.trim().to_string() + }; + let comments = Store::new(&cwd).ok().map(Arc::new); + let (events, _) = broadcast::channel(128); + let watcher = if cfg.watch { + Some(start_watcher( + cwd.clone(), + events.clone(), + cfg.on_change.clone(), + )?) + } else { + None + }; + let state = AppState { + cwd, + github_host, + ui: config::normalize_ui(cfg.ui), + comments, + events, + }; + let router = Router::new() + .route("/api/config", get(handle_config)) + .route("/api/events", get(handle_events)) + .route("/api/local-diff", get(handle_local_diff)) + .route("/api/branch-diff", get(handle_branch_diff)) + .route("/api/repo-context", get(handle_repo_context)) + .route( + "/api/comments", + get(handle_list_comments).post(handle_add_comment), + ) + .route("/api/comments/{thread_id}", delete(handle_delete_comment)) + .route( + "/api/comments/{thread_id}/replies", + post(handle_reply_comment), + ) + .route( + "/api/comments/{thread_id}/resolve", + post(handle_resolve_comment), + ) + .route( + "/api/comments/{thread_id}/reopen", + post(handle_reopen_comment), + ) + .route( + "/api/pull/{org}/{repo}/{number}", + get(handle_pull_request_info), + ) + .route("/api/patch/{org}/{repo}/{number}", get(handle_patch)) + .fallback(handle_static) + .with_state(state); + Ok(RunningServer { + router, + _watcher: watcher, + }) +} + +pub async fn serve(addr: SocketAddr, cfg: ServerConfig) -> anyhow::Result<()> { + let running = new(cfg)?; + let listener = tokio::net::TcpListener::bind(addr).await?; + serve_router(listener, running.router).await +} + +/// Header-read timeout for incoming connections. Bounds the slow-loris window +/// where a client opens a connection but never finishes sending request headers. +const READ_HEADER_TIMEOUT: Duration = Duration::from_secs(5); + +/// Serves `router` over `listener` with a per-connection header-read timeout. +/// +/// `axum::serve` exposes no header-read deadline, so we drive hyper's connection +/// builder directly. The timeout is enforced before routing (a tower request +/// timeout would run too late and would also break the long-lived SSE stream). +pub async fn serve_router( + listener: tokio::net::TcpListener, + router: axum::Router, +) -> anyhow::Result<()> { + use hyper::server::conn::http1; + use hyper_util::rt::{TokioIo, TokioTimer}; + use hyper_util::service::TowerToHyperService; + + loop { + let (stream, _) = listener.accept().await?; + let io = TokioIo::new(stream); + let service = TowerToHyperService::new(router.clone()); + tokio::spawn(async move { + let mut builder = http1::Builder::new(); + // header_read_timeout needs an explicit timer (hyper has no default). + builder + .timer(TokioTimer::new()) + .header_read_timeout(READ_HEADER_TIMEOUT); + // Per-connection errors (resets, header timeouts) are expected and + // isolated to that connection, so they are intentionally dropped. + // `with_upgrades` keeps the SSE stream (and any future websockets) + // working. + let _ = builder.serve_connection(io, service).with_upgrades().await; + }); + } +} + +async fn handle_config(State(state): State) -> impl IntoResponse { + let ui = &state.ui; + Json(ConfigResponse { + cwd: state.cwd.to_string_lossy().to_string(), + git_branch: git::branch(&state.cwd), + github_host: state.github_host, + color_scheme: valid(config::is_color_scheme, &ui.color_scheme), + diff_theme: valid(config::is_diff_theme, &ui.diff_theme), + diff_style: valid(config::is_diff_style, &ui.diff_style), + ui_font_family: ui.ui_font_family.clone(), + code_font_family: ui.code_font_family.clone(), + word_wrap: ui.word_wrap, + line_numbers: ui.line_numbers, + line_backgrounds: ui.line_backgrounds, + }) +} + +async fn handle_events( + State(state): State, +) -> Sse>> { + let stream = BroadcastStream::new(state.events.subscribe()).filter_map(|event| match event { + Ok(()) => Some(Ok(Event::default().event("diff").data("{}"))), + Err(_) => None, + }); + Sse::new(stream).keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(25)) + .text("ping"), + ) +} + +async fn handle_local_diff(State(state): State) -> Response { + match git::local_diff(&state.cwd) { + Ok(patch) => text(patch), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +async fn handle_branch_diff( + State(state): State, + Query(query): Query, +) -> Response { + let base = query.base.unwrap_or_default().trim().to_string(); + if base.is_empty() { + return error(StatusCode::BAD_REQUEST, "base query parameter is required"); + } + if !is_safe_ref_arg(&base) { + return error( + StatusCode::BAD_REQUEST, + format!("invalid base ref: {base:?}"), + ); + } + match git::branch_diff(&state.cwd, &base, dirty_enabled(query.dirty.as_deref())) { + Ok(patch) => text(patch), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +async fn handle_repo_context(State(state): State) -> impl IntoResponse { + // The two lookups are independent; run them concurrently so the handler's + // latency is the slower call, not the sum. + let (pr_json, repo_json) = tokio::join!( + gh::run(&state.cwd, &["pr", "view", "--json", "url,baseRefName"]), + gh::run( + &state.cwd, + &["repo", "view", "--json", "url,defaultBranchRef"], + ), + ); + let pr_json = pr_json.ok(); + let repo_json = repo_json.ok(); + let pr: serde_json::Value = pr_json + .and_then(|value| serde_json::from_str(&value).ok()) + .unwrap_or_default(); + let repo: serde_json::Value = repo_json + .and_then(|value| serde_json::from_str(&value).ok()) + .unwrap_or_default(); + let branch_base = [ + pr.pointer("/baseRefName").and_then(|v| v.as_str()), + repo.pointer("/defaultBranchRef/name") + .and_then(|v| v.as_str()), + Some("main"), + Some("master"), + ] + .into_iter() + .flatten() + .find_map(|candidate| git::resolve_local_ref(&state.cwd, candidate)) + .unwrap_or_default(); + Json(RepoContextResponse { + repo_url: repo + .pointer("/url") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + pr_url: pr + .pointer("/url") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + branch_base, + }) +} + +async fn handle_list_comments( + State(state): State, + Query(target): Query, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(pr) => { + match gh::list_pull_request_comments(&state.github_host, &pr.org, &pr.repo, &pr.number) + .await + { + Ok(threads) => { + (StatusCode::OK, Json(json!({ "threads": threads }))).into_response() + } + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } + } + CommentScope::Local => { + let Some(store) = state.comments else { + return comments_unavailable(); + }; + match store.list() { + Ok(threads) => { + (StatusCode::OK, Json(json!({ "threads": threads }))).into_response() + } + Err(err) => comment_error(err), + } + } + } +} + +async fn handle_add_comment( + State(state): State, + Query(target): Query, + Json(input): Json, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(pr) => { + match gh::add_pull_request_comment( + &state.github_host, + &pr.org, + &pr.repo, + &pr.number, + input, + ) + .await + { + Ok(thread) => (StatusCode::CREATED, Json(thread)).into_response(), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } + } + CommentScope::Local => { + let Some(store) = state.comments else { + return comments_unavailable(); + }; + match store.add_thread(input) { + Ok(thread) => (StatusCode::CREATED, Json(thread)).into_response(), + Err(err) => comment_error(err), + } + } + } +} + +async fn handle_delete_comment( + State(state): State, + Query(target): Query, + Path(thread_id): Path, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(_) => error( + StatusCode::BAD_REQUEST, + "deleting GitHub comments is not supported", + ), + CommentScope::Local => { + let Some(store) = state.comments else { + return comments_unavailable(); + }; + match store.delete(&thread_id) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => comment_error(err), + } + } + } +} + +async fn handle_reply_comment( + State(state): State, + Query(target): Query, + Path(thread_id): Path, + Json(input): Json, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(pr) => pr_thread_response( + gh::add_pull_request_reply( + &state.github_host, + &pr.org, + &pr.repo, + &pr.number, + &thread_id, + input, + ) + .await, + ), + CommentScope::Local => { + write_thread_or_error(state.comments, |store| store.add_reply(&thread_id, input)) + } + } +} + +async fn handle_resolve_comment( + State(state): State, + Query(target): Query, + Path(thread_id): Path, +) -> Response { + set_resolved(state, target, thread_id, true).await +} + +async fn handle_reopen_comment( + State(state): State, + Query(target): Query, + Path(thread_id): Path, +) -> Response { + set_resolved(state, target, thread_id, false).await +} + +async fn set_resolved( + state: AppState, + target: CommentTargetQuery, + thread_id: String, + resolved: bool, +) -> Response { + match comment_scope(&target) { + CommentScope::Invalid => invalid_pull_path(), + CommentScope::Pull(pr) => pr_thread_response( + gh::set_pull_request_thread_resolved( + &state.github_host, + &pr.org, + &pr.repo, + &pr.number, + &thread_id, + resolved, + ) + .await, + ), + CommentScope::Local => write_thread_or_error(state.comments, |store| { + if resolved { + store.resolve(&thread_id) + } else { + store.reopen(&thread_id) + } + }), + } +} + +async fn handle_pull_request_info( + State(state): State, + Path((org, repo, number)): Path<(String, String, String)>, +) -> Response { + if let Err(err) = validate_pr_path(&org, &repo, &number) { + return error(StatusCode::BAD_REQUEST, err); + } + match gh::pull_request_info(&state.github_host, &org, &repo, &number).await { + Ok(info) => (StatusCode::OK, Json(info)).into_response(), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +async fn handle_patch( + State(state): State, + Path((org, repo, number)): Path<(String, String, String)>, +) -> Response { + if let Err(err) = validate_pr_path(&org, &repo, &number) { + return error(StatusCode::BAD_REQUEST, err); + } + match gh::pull_request_patch(&state.github_host, &org, &repo, &number).await { + Ok(patch) => text(patch), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +async fn handle_static(uri: axum::http::Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; + let (asset_path, asset) = match Assets::get(path) { + Some(asset) => (path, asset), + None => match Assets::get("index.html") { + Some(asset) => ("index.html", asset), + None => { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "index.html not found in web assets", + ); + } + }, + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime_for(asset_path)) + .body(Body::from(asset.data.into_owned())) + .unwrap() +} + +fn write_thread_or_error( + store: Option>, + f: impl FnOnce(&Store) -> comments::Result, +) -> Response { + let Some(store) = store else { + return comments_unavailable(); + }; + match f(&store) { + Ok(thread) => (StatusCode::OK, Json(thread)).into_response(), + Err(err) => comment_error(err), + } +} + +fn comment_error(err: CommentError) -> Response { + match err { + CommentError::NotFound => error(StatusCode::NOT_FOUND, err), + CommentError::Validation(_) => error(StatusCode::BAD_REQUEST, err), + _ => error(StatusCode::INTERNAL_SERVER_ERROR, err), + } +} + +fn text(patch: String) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Body::from(patch)) + .unwrap() +} + +fn error(status: StatusCode, err: impl std::fmt::Display) -> Response { + (status, Json(json!({ "error": err.to_string() }))).into_response() +} + +fn valid(check: impl Fn(&str) -> bool, value: &str) -> String { + if check(value) { + value.to_string() + } else { + String::new() + } +} + +fn dirty_enabled(value: Option<&str>) -> bool { + let value = value.unwrap_or_default().trim(); + ["1", "true", "yes", "on"] + .iter() + .any(|candidate| value.eq_ignore_ascii_case(candidate)) +} + +fn is_safe_ref_arg(ref_name: &str) -> bool { + if ref_name.is_empty() + || ref_name.starts_with('-') + || ref_name.contains("..") + || ref_name.contains('~') + || ref_name.contains('^') + || ref_name == "@" + || ref_name.contains('{') + || ref_name.contains('}') + || ref_name.contains('\\') + { + return false; + } + !ref_name + .chars() + .any(|c| c <= ' ' || c == '\u{7f}' || matches!(c, ':' | '?' | '*' | '[')) +} + +struct PullTarget { + org: String, + repo: String, + number: String, +} + +enum CommentScope { + Local, + Pull(PullTarget), + Invalid, +} + +/// Empty org/repo/number means local comments; otherwise the trio must pass the +/// same validators as the PR routes, or the request is rejected with 400 +/// (`Invalid`). +fn comment_scope(target: &CommentTargetQuery) -> CommentScope { + let org = target.org.as_deref().unwrap_or_default(); + let repo = target.repo.as_deref().unwrap_or_default(); + let number = target.number.as_deref().unwrap_or_default(); + if org.is_empty() && repo.is_empty() && number.is_empty() { + return CommentScope::Local; + } + if safe_path_part(org) && safe_path_part(repo) && pull_number(number) { + CommentScope::Pull(PullTarget { + org: org.to_string(), + repo: repo.to_string(), + number: number.to_string(), + }) + } else { + CommentScope::Invalid + } +} + +fn invalid_pull_path() -> Response { + error(StatusCode::BAD_REQUEST, "invalid pull request path") +} + +fn comments_unavailable() -> Response { + error( + StatusCode::SERVICE_UNAVAILABLE, + "local comments require a git repository", + ) +} + +fn pr_thread_response(result: anyhow::Result) -> Response { + match result { + Ok(thread) => (StatusCode::OK, Json(thread)).into_response(), + Err(err) => error(StatusCode::BAD_GATEWAY, err), + } +} + +fn validate_pr_path(org: &str, repo: &str, number: &str) -> anyhow::Result<()> { + if safe_path_part(org) && safe_path_part(repo) && pull_number(number) { + Ok(()) + } else { + anyhow::bail!("invalid pull request path") + } +} + +fn pull_number(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) && !value.starts_with('0') +} + +fn safe_path_part(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('-') + && !value.contains("..") + && !value.contains('/') + && !value.contains('\\') + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +fn mime_for(path: &str) -> &'static str { + match FsPath::new(path).extension().and_then(|ext| ext.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("json") => "application/json; charset=utf-8", + Some("map") => "application/json; charset=utf-8", + Some("txt") => "text/plain; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("ico") => "image/x-icon", + Some("webp") => "image/webp", + Some("wasm") => "application/wasm", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + Some("ttf") => "font/ttf", + _ => "application/octet-stream", + } +} + +const WATCH_DEBOUNCE: Duration = Duration::from_millis(150); + +// Git-internal files whose changes mean the repository state moved (branch +// switch, commit, stage). +const GIT_STATE_ENTRIES: [&str; 8] = [ + "HEAD", + "index", + "index.lock", + "packed-refs", + "packed-refs.lock", + "refs", + "logs", + "COMMIT_EDITMSG", +]; + +/// One debounced batch of filesystem activity. +struct WatchTick { + /// A watched `.git` state file changed (branch switch, commit, stage). + git_state: bool, + /// Repository-relative, forward-slash paths of relevant (non-ignored) changes. + paths: Vec, +} + +fn start_watcher( + cwd: PathBuf, + events: broadcast::Sender<()>, + on_change: Option, +) -> anyhow::Result { + use std::sync::mpsc::{self, RecvTimeoutError}; + + // notify invokes the event handler on its own (non-tokio) thread, so the + // handler only classifies paths and forwards a tick over a sync channel. A + // dedicated debounce thread coalesces bursts and resolves the changed files + // 150ms after the last event. + let (tx, rx) = mpsc::channel::(); + let status_cwd = cwd.clone(); + std::thread::spawn(move || { + // Repository handle for `git status` lookups; lives only on this thread. + let repo = git::discover(&status_cwd).ok(); + loop { + let mut pending: BTreeSet = BTreeSet::new(); + let mut git_state = false; + match rx.recv() { + Ok(tick) => merge_tick(&mut pending, &mut git_state, tick), + Err(_) => return, // watcher dropped + } + loop { + match rx.recv_timeout(WATCH_DEBOUNCE) { + Ok(tick) => merge_tick(&mut pending, &mut git_state, tick), + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => break, + } + } + + // Resolve which of the changed paths git actually reports, then + // broadcast only when the effective repository state changed. + let status = repo.as_ref().and_then(|repo| git::status_map(repo).ok()); + let changed = match &status { + Some(map) => changed_files_for_events(&pending, map), + None => changed_files_from_events(&pending), + }; + let broadcast = !changed.is_empty() || git_state; + if broadcast { + let _ = events.send(()); + if let Some(on_change) = &on_change { + // Isolate the callback: a panic here (e.g. println! on a + // broken stdout pipe) must not unwind and kill this thread, + // which would silently stop all future SSE broadcasts. + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| on_change(changed))); + } + } + } + }); + + // A dedicated repo handle for ignore lookups on notify's handler thread. + // git2::Repository is Send but not Sync; single-threaded access here is sound. + let repo = git::discover(&cwd).ok(); + let git_dir = repo.as_ref().map(|repo| repo.path().to_path_buf()); + // Kept for the external-git-dir watch below (the closure moves `git_dir`). + let external_git_dir = git_dir.clone().filter(|dir| !dir.starts_with(&cwd)); + let event_cwd = cwd.clone(); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + let Ok(event) = event else { + return; + }; + let mut tick = WatchTick { + git_state: false, + paths: Vec::new(), + }; + for path in &event.paths { + // Git-state files take priority (checked before the .git skip below). + if git_dir + .as_deref() + .is_some_and(|git_dir| is_git_state_file(git_dir, path)) + { + tick.git_state = true; + continue; + } + // Skip VCS/temp files and gitignored paths: they never reach the diff. + if is_structurally_ignored(path) + || repo + .as_ref() + .is_some_and(|repo| git::is_path_ignored(repo, path)) + { + continue; + } + if let Ok(rel) = path.strip_prefix(&event_cwd) { + let rel = rel.to_string_lossy().replace('\\', "/"); + if !rel.is_empty() { + tick.paths.push(rel); + } + } + } + if tick.git_state || !tick.paths.is_empty() { + let _ = tx.send(tick); + } + })?; + watcher.watch(&cwd, RecursiveMode::Recursive)?; + // For linked worktrees and submodules the git dir lives outside the working + // tree, so the recursive cwd watch never sees its HEAD/index/refs changes. + // Watch it explicitly (best-effort; only git-state refreshes depend on it). + if let Some(git_dir) = external_git_dir { + let _ = watcher.watch(&git_dir, RecursiveMode::Recursive); + } + Ok(watcher) +} + +fn merge_tick(pending: &mut BTreeSet, git_state: &mut bool, tick: WatchTick) { + if tick.git_state { + *git_state = true; + } + pending.extend(tick.paths); +} + +/// Whether `path` is a watched `.git` state file (relative to the git dir). +fn is_git_state_file(git_dir: &FsPath, path: &FsPath) -> bool { + let Ok(rel) = path.strip_prefix(git_dir) else { + return false; + }; + match rel.components().next() { + None => true, // the git dir itself + Some(std::path::Component::Normal(first)) => first + .to_str() + .is_some_and(|name| GIT_STATE_ENTRIES.contains(&name)), + _ => false, + } +} + +/// Intersects the changed event paths with git's reported status: an event path +/// matches directly, or matches every status entry beneath it when the event was +/// on a directory. +fn changed_files_for_events( + events: &BTreeSet, + status: &BTreeMap, +) -> Vec { + if events.is_empty() || status.is_empty() { + return Vec::new(); + } + let mut matches: BTreeMap = BTreeMap::new(); + for event in events { + let event = event.trim_matches('/'); + if event.is_empty() { + continue; + } + if let Some(action) = status.get(event) { + matches.insert(event.to_string(), *action); + continue; + } + let prefix = format!("{event}/"); + for (path, action) in status { + if path.starts_with(&prefix) { + matches.insert(path.clone(), *action); + } + } + } + matches + .into_iter() + .map(|(path, action)| git::ChangedFile { path, action }) + .collect() +} + +/// Fallback when `git status` is unavailable: report every event path as +/// modified. +fn changed_files_from_events(events: &BTreeSet) -> Vec { + events + .iter() + .filter_map(|event| { + let event = event.trim_matches('/'); + (!event.is_empty()).then(|| git::ChangedFile { + path: event.to_string(), + action: git::ChangeAction::Modified, + }) + }) + .collect() +} + +const IGNORED_DIRS: [&str; 4] = ["node_modules", ".git", ".hg", ".svn"]; + +/// Always-ignored paths, independent of gitignore: VCS internals and the comment +/// store's atomic-write temp files (which live in a tracked `.diffs/` dir, so +/// gitignore would not catch them, and watching them would cause reload loops). +/// Matches on whole path components, so it is separator-agnostic on Windows. +fn is_structurally_ignored(path: &FsPath) -> bool { + path.components().any(|component| { + let std::path::Component::Normal(name) = component else { + return false; + }; + let Some(name) = name.to_str() else { + return false; + }; + IGNORED_DIRS.contains(&name) || (name.starts_with(".comments-") && name.ends_with(".json")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_safe_ref_arg_accepts_branch_like_refs() { + for ok in ["main", "origin/main", "feature/x", "release-1.2"] { + assert!(is_safe_ref_arg(ok), "{ok} should be safe"); + } + for bad in [ + "", "-flag", "a..b", "HEAD~1", "x^", "@", "a{b", "a}b", "a\\b", "a b", "a:b", "a?b", + "a*b", "a[b", + ] { + assert!(!is_safe_ref_arg(bad), "{bad:?} should be rejected"); + } + } + + #[test] + fn pr_path_validators() { + assert!(pull_number("123")); + assert!(!pull_number("0")); + assert!(!pull_number("012")); + assert!(!pull_number("")); + assert!(!pull_number("12a")); + + assert!(safe_path_part("org.repo-1_x")); + assert!(!safe_path_part("")); + assert!(!safe_path_part("-x")); + assert!(!safe_path_part("a/b")); + assert!(!safe_path_part("a..b")); + assert!(!safe_path_part("a b")); + } + + #[test] + fn comment_scope_classification() { + let local = CommentTargetQuery { + org: None, + repo: None, + number: None, + }; + assert!(matches!(comment_scope(&local), CommentScope::Local)); + + let pull = CommentTargetQuery { + org: Some("org".into()), + repo: Some("repo".into()), + number: Some("123".into()), + }; + match comment_scope(&pull) { + CommentScope::Pull(t) => assert_eq!( + (t.org, t.repo, t.number), + ("org".into(), "repo".into(), "123".into()) + ), + _ => panic!("expected pull scope"), + } + + let invalid = CommentTargetQuery { + org: Some("../bad".into()), + repo: Some("repo".into()), + number: Some("123".into()), + }; + assert!(matches!(comment_scope(&invalid), CommentScope::Invalid)); + } + + #[test] + fn git_state_file_detection() { + let git_dir = FsPath::new("/repo/.git"); + assert!(is_git_state_file(git_dir, FsPath::new("/repo/.git/HEAD"))); + assert!(is_git_state_file(git_dir, FsPath::new("/repo/.git/index"))); + assert!(is_git_state_file( + git_dir, + FsPath::new("/repo/.git/refs/heads/main") + )); + assert!(is_git_state_file( + git_dir, + FsPath::new("/repo/.git/logs/HEAD") + )); + assert!(is_git_state_file(git_dir, FsPath::new("/repo/.git"))); + assert!(!is_git_state_file( + git_dir, + FsPath::new("/repo/.git/objects/ab/cd") + )); + assert!(!is_git_state_file( + git_dir, + FsPath::new("/repo/src/main.rs") + )); + } + + #[test] + fn structural_ignore_matches_components() { + assert!(is_structurally_ignored(FsPath::new( + "/repo/node_modules/x/y.js" + ))); + assert!(is_structurally_ignored(FsPath::new("/repo/.git/HEAD"))); + assert!(is_structurally_ignored(FsPath::new( + "/repo/.diffs/.comments-ab12.json" + ))); + assert!(!is_structurally_ignored(FsPath::new( + "/repo/src/.gitignore" + ))); + assert!(!is_structurally_ignored(FsPath::new("/repo/my.git/x"))); + } + + #[test] + fn changed_files_for_events_intersects_status() { + let mut status = BTreeMap::new(); + status.insert("src/a.rs".to_string(), git::ChangeAction::Modified); + status.insert("src/b.rs".to_string(), git::ChangeAction::Added); + status.insert("docs/c.md".to_string(), git::ChangeAction::Deleted); + + // Direct hit + directory-prefix expansion; "missing" is dropped. + let events: BTreeSet = ["src/a.rs", "docs", "missing"] + .iter() + .map(|s| s.to_string()) + .collect(); + let changed = changed_files_for_events(&events, &status); + let got: Vec<(&str, git::ChangeAction)> = changed + .iter() + .map(|c| (c.path.as_str(), c.action)) + .collect(); + assert_eq!( + got, + vec![ + ("docs/c.md", git::ChangeAction::Deleted), + ("src/a.rs", git::ChangeAction::Modified), + ] + ); + } + + #[test] + fn changed_files_from_events_marks_modified() { + let events: BTreeSet = ["b.rs", "a.rs"].iter().map(|s| s.to_string()).collect(); + let changed = changed_files_from_events(&events); + assert_eq!(changed.len(), 2); + assert_eq!(changed[0].path, "a.rs"); // sorted + assert!( + changed + .iter() + .all(|c| c.action == git::ChangeAction::Modified) + ); + } +} diff --git a/src/webassets.rs b/src/webassets.rs new file mode 100644 index 0000000..f6a7ddb --- /dev/null +++ b/src/webassets.rs @@ -0,0 +1,5 @@ +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "web/dist"] +pub struct Assets; diff --git a/tests/diff_parity.rs b/tests/diff_parity.rs new file mode 100644 index 0000000..bd5b13c --- /dev/null +++ b/tests/diff_parity.rs @@ -0,0 +1,283 @@ +//! Diff byte-parity gate (MIGRATION §4). +//! +//! The frontend's `@pierre/diffs` parser consumes git's exact unified-diff text, +//! so the libgit2-backed `local_diff`/`branch_diff` must reproduce it. The oracle +//! here is **git itself**. Git is the ground truth the parser targets, and this +//! keeps the harness valid without depending on another implementation. +//! +//! Comparison is per-file: libgit2 emits every file (including untracked) in one +//! path-sorted pass, whereas git's `diff HEAD` + per-file `--no-index` untracked +//! pipeline appends untracked last, so the global byte order differs legitimately. +//! The parser handles each `diff --git` block independently, so per-file byte +//! parity is the meaningful contract. + +use std::collections::BTreeMap; +use std::path::Path; +use std::{fs, process::Command}; + +fn git(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); +} + +fn git_output(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("run git"); + assert!(output.status.success(), "git {args:?} failed"); + String::from_utf8(output.stdout).expect("git output is utf8") +} + +fn init_repo(dir: &Path) { + git(dir, &["init"]); + git(dir, &["config", "user.email", "diffs@example.com"]); + git(dir, &["config", "user.name", "Diffs Test"]); + // Pin diff rendering so the oracle is deterministic across host git configs. + git(dir, &["config", "core.autocrlf", "false"]); + git(dir, &["config", "diff.renames", "true"]); +} + +/// Appends a `git diff --no-index` block for every untracked file, mirroring the +/// server's `untrackedPatch`. `--no-index` exits 1 when files differ. +fn append_untracked(dir: &Path, patch: &mut String) { + let untracked = git_output(dir, &["ls-files", "--others", "--exclude-standard", "-z"]); + for name in untracked.split('\0').filter(|name| !name.is_empty()) { + let out = Command::new("git") + .args([ + "diff", + "--no-ext-diff", + "--patch", + "--no-index", + "--", + "/dev/null", + name, + ]) + .current_dir(dir) + .output() + .expect("run git no-index"); + assert_eq!(out.status.code(), Some(1), "git diff --no-index for {name}"); + if !patch.is_empty() && !patch.ends_with('\n') { + patch.push('\n'); + } + patch.push_str(&String::from_utf8(out.stdout).expect("no-index output is utf8")); + } +} + +/// Reconstructs the server's `localDiff` reference (HEAD present). +fn git_local_patch(dir: &Path) -> String { + let mut patch = git_output( + dir, + &[ + "diff", + "--no-ext-diff", + "--patch", + "--submodule=diff", + "HEAD", + "--", + ], + ); + append_untracked(dir, &mut patch); + patch +} + +/// Reconstructs the server's `localDiff` reference for a repo with no HEAD: +/// staged (`--cached`) then unstaged then untracked. +fn git_local_patch_no_head(dir: &Path) -> String { + let mut patch = git_output( + dir, + &[ + "diff", + "--no-ext-diff", + "--patch", + "--submodule=diff", + "--cached", + "--", + ], + ); + let unstaged = git_output( + dir, + &["diff", "--no-ext-diff", "--patch", "--submodule=diff", "--"], + ); + if !patch.is_empty() && !patch.ends_with('\n') { + patch.push('\n'); + } + patch.push_str(&unstaged); + append_untracked(dir, &mut patch); + patch +} + +/// Reconstructs `branchDiff` (three-dot) reference. +fn git_branch_patch(dir: &Path, base: &str) -> String { + git_output( + dir, + &[ + "diff", + "--no-ext-diff", + "--patch", + "--submodule=diff", + &format!("{base}...HEAD"), + "--", + ], + ) +} + +/// Reconstructs `branchDiffWithDirty` reference: merge-base to working tree, +/// plus untracked files. +fn git_branch_dirty_patch(dir: &Path, base: &str) -> String { + let merge_base = git_output(dir, &["merge-base", base, "HEAD"]) + .trim() + .to_string(); + let mut patch = git_output( + dir, + &[ + "diff", + "--no-ext-diff", + "--patch", + "--submodule=diff", + &merge_base, + "--", + ], + ); + append_untracked(dir, &mut patch); + patch +} + +/// Splits a unified diff into per-file blocks keyed by the `diff --git` header. +fn split_files(patch: &str) -> BTreeMap { + let mut blocks = BTreeMap::new(); + let mut key: Option = None; + let mut buf = String::new(); + for line in patch.split_inclusive('\n') { + if line.starts_with("diff --git ") { + if let Some(key) = key.take() { + blocks.insert(key, std::mem::take(&mut buf)); + } + key = Some(line.trim_end().to_string()); + } + buf.push_str(line); + } + if let Some(key) = key.take() { + blocks.insert(key, buf); + } + blocks +} + +fn assert_per_file_parity(rust: &str, git: &str) { + assert_eq!( + split_files(rust), + split_files(git), + "per-file diff text must match git byte-for-byte" + ); +} + +#[test] +fn local_diff_matches_git_for_rich_fixture() { + let repo = tempfile::tempdir().unwrap(); + let dir = repo.path(); + init_repo(dir); + + fs::write(dir.join("modified.txt"), "before\n").unwrap(); + fs::write(dir.join("deleted.txt"), "bye\n").unwrap(); + fs::write(dir.join("renamed.txt"), "rename me unchanged\n").unwrap(); + fs::write(dir.join("nonewline.txt"), "first\nsecond\n").unwrap(); + fs::write(dir.join("crlf.txt"), "a\r\nb\r\n").unwrap(); + fs::write(dir.join("binary.bin"), [0u8, 1, 2, 3, 0, 255, 10]).unwrap(); + fs::write(dir.join("mode.sh"), "echo hi\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "initial"]); + + // Worktree + index changes covering the §4 divergence points. + fs::write(dir.join("modified.txt"), "before\nafter\n").unwrap(); + fs::remove_file(dir.join("deleted.txt")).unwrap(); + git(dir, &["mv", "renamed.txt", "renamed-new.txt"]); // staged rename + fs::write(dir.join("nonewline.txt"), "first\nsecond").unwrap(); // drop trailing newline + fs::write(dir.join("crlf.txt"), "a\r\nb\r\nc\r\n").unwrap(); + fs::write(dir.join("binary.bin"), [0u8, 9, 9, 9, 0, 1, 10]).unwrap(); + fs::write(dir.join("untracked.txt"), "new\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir.join("mode.sh"), fs::Permissions::from_mode(0o755)).unwrap(); + } + + let rust_patch = diffs::git::local_diff(dir).unwrap(); + let git_patch = git_local_patch(dir); + assert_per_file_parity(&rust_patch, &git_patch); +} + +#[test] +fn local_diff_matches_git_for_empty_repo() { + let repo = tempfile::tempdir().unwrap(); + let dir = repo.path(); + init_repo(dir); + + // No commit -> no HEAD. Mix of staged, unstaged-modified, and untracked. + fs::write(dir.join("staged.txt"), "staged\n").unwrap(); + git(dir, &["add", "staged.txt"]); + fs::write(dir.join("staged.txt"), "staged\nedited\n").unwrap(); // staged + further unstaged edit + fs::write(dir.join("untracked.txt"), "loose\n").unwrap(); + + let rust_patch = diffs::git::local_diff(dir).unwrap(); + let git_patch = git_local_patch_no_head(dir); + assert_per_file_parity(&rust_patch, &git_patch); +} + +#[test] +fn branch_diff_three_dot_matches_git() { + let repo = tempfile::tempdir().unwrap(); + let dir = repo.path(); + init_repo(dir); + + fs::write(dir.join("base.txt"), "base\n").unwrap(); + fs::write(dir.join("shared.txt"), "v1\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "initial"]); + git(dir, &["branch", "-M", "main"]); // deterministic base name across git versions + + // Diverge main with a commit the feature branch should not see (three-dot). + git(dir, &["checkout", "-q", "-b", "feature"]); + fs::write(dir.join("feature.txt"), "added on feature\n").unwrap(); + fs::write(dir.join("shared.txt"), "v2\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "feature work"]); + + git(dir, &["checkout", "-q", "main"]); + fs::write(dir.join("base.txt"), "base changed on main\n").unwrap(); + git(dir, &["commit", "-am", "main moves on"]); + git(dir, &["checkout", "-q", "feature"]); + + let rust_patch = diffs::git::branch_diff(dir, "main", false).unwrap(); + let git_patch = git_branch_patch(dir, "main"); + assert_per_file_parity(&rust_patch, &git_patch); +} + +#[test] +fn branch_diff_include_dirty_matches_git() { + let repo = tempfile::tempdir().unwrap(); + let dir = repo.path(); + init_repo(dir); + + fs::write(dir.join("base.txt"), "base\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "initial"]); + git(dir, &["branch", "-M", "main"]); // deterministic base name across git versions + + git(dir, &["checkout", "-q", "-b", "feature"]); + fs::write(dir.join("committed.txt"), "committed on feature\n").unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-m", "feature commit"]); + + // Dirty working tree on top of the committed branch work. + fs::write(dir.join("base.txt"), "base dirty edit\n").unwrap(); + fs::write(dir.join("untracked.txt"), "loose\n").unwrap(); + + let rust_patch = diffs::git::branch_diff(dir, "main", true).unwrap(); + let git_patch = git_branch_dirty_patch(dir, "main"); + assert_per_file_parity(&rust_patch, &git_patch); +} diff --git a/web/go.mod b/web/go.mod deleted file mode 100644 index 061c92d..0000000 --- a/web/go.mod +++ /dev/null @@ -1,4 +0,0 @@ -module github.com/imfing/diffs-cli/web-src - -go 1.26 -