diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index db55317..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: Bug Report -about: Report a bug to help us improve -title: '[BUG] ' -labels: bug -assignees: '' ---- - -## Describe the Bug -A clear and concise description of what the bug is. - -## To Reproduce -Steps to reproduce the behavior: -1. Run `git-scope ...` -2. Press `...` -3. See error - -## Expected Behavior -What you expected to happen. - -## Screenshots -If applicable, add screenshots or terminal output. - -## Environment -- **OS**: [e.g., macOS 14.1, Ubuntu 22.04] -- **git-scope version**: [e.g., 1.2.1] -- **Go version** (if built from source): [e.g., 1.21] -- **Terminal**: [e.g., iTerm2, Alacritty, Windows Terminal] - -## Additional Context -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..c269aa7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,53 @@ +name: Bug report +description: Something in git-scope is broken or wrong +title: "[BUG] " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting! Security issues go through a [private advisory](https://github.com/Bharath-code/git-scope/security/advisories/new), not here. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What you did, what you expected, and what happened instead. + placeholder: | + 1. Run `git-scope ~/code` + 2. Press `...` + 3. Expected X, saw Y + validations: + required: true + - type: input + id: version + attributes: + label: git-scope version + description: Output of `git-scope --version` + placeholder: git-scope v1.4.0 + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: [macOS, Linux, Windows, Other] + validations: + required: true + - type: dropdown + id: surface + attributes: + label: Where did it happen? + options: [TUI dashboard, status --json / scan, MCP server (git-scope mcp), init / config, Other] + validations: + required: true + - type: input + id: terminal + attributes: + label: Terminal / MCP client + placeholder: iTerm2, Windows Terminal, Claude Code, Cursor... + - type: textarea + id: logs + attributes: + label: Output or screenshots + description: Paste relevant output. Redact private repo paths if needed. + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b06b971 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Question or idea to discuss + url: https://github.com/Bharath-code/git-scope/discussions + about: Ask questions and share ideas in Discussions. + - name: Report a security vulnerability + url: https://github.com/Bharath-code/git-scope/security/advisories/new + about: Please report vulnerabilities privately, not as an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index a487f02..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature Request -about: Suggest an idea for git-scope -title: '[FEATURE] ' -labels: enhancement -assignees: '' ---- - -## Problem -A clear description of the problem you're trying to solve. -Example: "I'm frustrated when I have to..." - -## Proposed Solution -Describe the solution you'd like. - -## Alternatives Considered -Any alternative solutions or features you've considered. - -## Additional Context -Add any mockups, screenshots, or examples here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a46c748 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Suggest an idea for git-scope +title: "[FEATURE] " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do, and what gets in the way today? + placeholder: "When I run several agents across worktrees, I can't tell..." + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What would you like git-scope to do? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: principles + attributes: + label: Fit with git-scope's principles + options: + - label: This keeps git-scope read-only and local-first (no network calls, no telemetry) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c77babb..6b7ac31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,14 +8,20 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: - test: - name: Test & Lint + checks: + name: Format, tidy, vet, vulncheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: true @@ -29,32 +35,56 @@ jobs: exit 1 fi + - name: Verify go.mod / go.sum are tidy + run: go mod tidy -diff + - name: Vet run: go vet ./... + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Release ships binaries for all three; path and git handling differ per OS. + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + - name: Test (race + coverage) + shell: bash run: go test -race -coverprofile=coverage.out -covermode=atomic ./... - name: Coverage summary + shell: bash run: go tool cover -func=coverage.out | tail -n 1 - - name: govulncheck - run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... - lint: name: golangci-lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: true - name: golangci-lint - # v9 supports golangci-lint v2 (correct /v2 module path for goinstall). - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: # Pinned for reproducible runs — bump deliberately, not on every push. version: v2.12.2 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 845788b..408d81d 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,8 +8,6 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: "pages" @@ -17,24 +15,30 @@ concurrency: jobs: deploy: + permissions: + contents: read + pages: write # publish the site + id-token: write # verify the deployment origin environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 with: enablement: true - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: './docs' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31069de..03cbde5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,25 +6,39 @@ on: - 'v*' permissions: - contents: write + contents: read jobs: goreleaser: runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub release + id-token: write # sign build provenance via Sigstore + attestations: write # store provenance on the repo steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - + + - name: Install syft (SBOM generation) + uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 + - name: Run GoReleaser - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: - version: v2.18.2 + version: v2.18.3 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + + # Verify with: gh attestation verify --repo Bharath-code/git-scope + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-checksums: dist/checksums.txt diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..cce38e6 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,40 @@ +# OpenSSF Scorecard: supply-chain security checks, published to +# https://scorecard.dev/viewer/?uri=github.com/Bharath-code/git-scope +name: Scorecard + +on: + branch_protection_rule: + schedule: + - cron: '27 4 * * 1' + push: + branches: [main] + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write # upload SARIF to code scanning + id-token: write # publish results to the public Scorecard API + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index c711f00..57f2002 100644 --- a/.gitignore +++ b/.gitignore @@ -31,11 +31,8 @@ Thumbs.db /materials demo.gif demo.tape -git-scope -git-scope-test -git-scope-final +/git-scope-test +/git-scope-final docs/images/fallback_pic docs/images/mobile docs/images/desktop -coverage.out -PROJECT-STRATEGY-AND-CAREER-PLAYBOOK.md diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e51e774 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,38 @@ +version: "2" + +linters: + # standard = errcheck, govet, ineffassign, staticcheck, unused + default: standard + enable: + - bodyclose + - errorlint # wrapped errors compared with == / type switches + - gosec # git-scope runs git in repos it did not create + - misspell + - nolintlint # every //nolint must name a linter and say why + - revive + - unconvert + settings: + gosec: + excludes: + # Running git/editors with repo paths and user-chosen editors is the + # product; Command() in internal/gitstatus neutralises unsafe config. + - G204 + # Reading the config/state file the user points us at is intended. + - G304 + nolintlint: + require-explanation: true + require-specific: true + revive: + rules: + - name: exported + disabled: true # internal packages; doc comments are encouraged, not enforced + exclusions: + presets: + - std-error-handling + rules: + - path: _test\.go + linters: [gosec, errcheck] + +formatters: + enable: + - gofmt diff --git a/.goreleaser.yml b/.goreleaser.yml index 1b49bc0..1f82b72 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,10 +3,6 @@ project_name: git-scope -before: - hooks: - - go mod tidy - builds: - id: git-scope main: ./cmd/git-scope @@ -28,11 +24,15 @@ archives: name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" format_overrides: - goos: windows - format: zip + formats: [zip] checksum: name_template: 'checksums.txt' +# SPDX SBOM per archive (needs syft on PATH; the release workflow installs it) +sboms: + - artifacts: archive + changelog: sort: asc filters: @@ -40,16 +40,22 @@ changelog: - '^docs:' - '^test:' -brews: +# Casks replace the deprecated `brews` formula. One-time tap migration, done in +# Bharath-code/homebrew-tap after the first cask release: add +# tap_migrations.json with {"git-scope": "git-scope"} and delete Formula/git-scope.rb. +homebrew_casks: - name: git-scope repository: owner: Bharath-code name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" homepage: "https://github.com/Bharath-code/git-scope" - description: "A fast TUI to see the status of all git repositories" + description: "Repo-state layer for AI coding agents: see uncommitted and unpushed work across every git repo" license: "MIT" - install: | - bin.install "git-scope" - test: | - system "#{bin}/git-scope", "help" + # Binaries are not notarized; drop the quarantine flag so macOS will run them. + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/git-scope"] + end diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c7407..d96016f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `git-scope status --json`: every repo's branch, uncommitted files, + ahead/behind, unpushed commits, last commit, and attention tier + (ACTION/WATCH/CLEAN) with a reason, sorted most urgent first. +- `git-scope mcp`: a read-only, local-only MCP server for AI coding agents + (Claude Code, Cursor) with `list_repos`, `needs_attention` and + `repo_status` tools. See "Use with Claude Code / Cursor" in the README. +- Releases now include an SPDX SBOM per archive and signed build provenance + (`gh attestation verify`). - Editor commands support a `{path}` placeholder for the repo path, e.g. `editor: "lazygit --path {path}"` (#41). ### Fixed +- Linked git worktrees are now scanned (their `.git` is a file, so they were + skipped entirely). +- Commits on a branch that was never pushed, or on a detached HEAD, now count + as unpushed instead of showing the repo as clean. Repos with no remote are + unaffected. +- Merge conflicts now count as uncommitted work instead of a clean tree. +- Each git call is bounded by a 10s timeout, so one hung repo cannot stall + a scan. - `editor: lazygit` (and `gitui`, `tig`) now launches correctly instead of treating the repo path as a subcommand. All editors start with the working directory set to the selected repo (#23, #41). +### Security +- Config, cache and nudge-state files are now created owner-only + (`0700` dirs, `0600` files) instead of world-readable; the cache lists every + scanned repo path. + ### Changed +- Homebrew: published as a cask instead of the deprecated GoReleaser formula. +- CI tests on Linux, macOS and Windows, checks `go.mod` tidiness, pins every + GitHub Action to a commit SHA, and runs stricter linters (gosec, errorlint, + revive, misspell). OpenSSF Scorecard runs weekly. +- Bug and feature reports use issue forms; blank issues are disabled. - Upgraded TUI libraries: bubbletea 0.26 → 1.3, bubbles 0.18 → 1.0, lipgloss 0.11 → 1.1; mvdan.cc/sh 3.7 → 3.14 (#38). - Bumped GitHub Actions dependencies (#39). diff --git a/README.md b/README.md index 235d88a..a33b64c 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ git-scope git-scope # Launch TUI dashboard git-scope init # Create config file interactively git-scope scan # Scan and print repos (JSON) +git-scope status --json # Repo state sorted by attention (JSON) +git-scope mcp # Read-only MCP server on stdio for AI agents git-scope scan-all # Full system scan from home directory git-scope issue # Open GitHub issues page in browser git-scope -h # Show help @@ -84,6 +86,42 @@ git-scope -h # Show help ----- +## 🤖 Use with Claude Code / Cursor + +Running several coding agents across repos and worktrees? `git-scope mcp` lets an agent see every repo's state — uncommitted files, unpushed commits, ahead/behind — before it starts or ends a task. It is read-only, local-only (no network calls, no telemetry) and scans the roots from your config. + +| Tool | Returns | +|------|---------| +| `list_repos` | Every repo, most urgent first | +| `needs_attention` | Only `ACTION` / `WATCH` repos (unpushed, behind, or uncommitted) | +| `repo_status(path)` | One repo under your configured roots | + +**Claude Code:** +```bash +claude mcp add git-scope -- git-scope mcp +``` + +**Cursor** (`~/.cursor/mcp.json`), or any client using the `mcpServers` format: +```json +{ + "mcpServers": { + "git-scope": { + "command": "git-scope", + "args": ["mcp"] + } + } +} +``` + +Append directories to scan specific roots instead of your config: `"args": ["mcp", "~/code", "~/work"]`. + +Same data for scripts: +```bash +git-scope status --json | jq '.[] | select(.tier != "CLEAN") | {path, reason}' +``` + +----- + ## 🆚 git-scope vs. lazygit diff --git a/SECURITY.md b/SECURITY.md index 902aadb..f7bee54 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,22 +2,43 @@ ## Supported Versions +Only the latest minor release receives security fixes. + | Version | Supported | | ------- | ------------------ | -| 1.2.x | :white_check_mark: | -| < 1.2 | :x: | +| 1.4.x | :white_check_mark: | +| < 1.4 | :x: | ## Reporting a Vulnerability -If you discover a security vulnerability, please report it responsibly: +**Do not open a public issue.** Report privately through either: + +1. **GitHub private advisory (preferred):** [Report a vulnerability](https://github.com/Bharath-code/git-scope/security/advisories/new) +2. **Email:** kumarbharath63@gmail.com, with "git-scope security" in the subject -1. **Do NOT** open a public issue. -2. Email the maintainer directly at [kumarbharath63@gmail.com]. -3. Include a detailed description of the vulnerability and steps to reproduce. +Please include the affected version, OS, and steps to reproduce. -You can expect: +What to expect: - Acknowledgment within 48 hours -- Regular updates on the fix progress -- Credit in the release notes (unless you prefer to remain anonymous) +- An assessment and fix timeline within 7 days +- Credit in the release notes and advisory (unless you prefer to stay anonymous) + +## Scope + +git-scope promises to be **read-only, local-first, and safe at work**. These are in scope: + +- Any way scanning a repository can **modify it** (git-scope must never write to a repo) +- Any way a malicious repository can make git-scope **execute code**, for example through + repo-local git config such as `core.fsmonitor` (see `internal/gitstatus`) +- Any **network call or data leaving the machine**, from the CLI, TUI, or `git-scope mcp` +- The MCP server exposing repositories **outside the configured roots** +- Config, cache, or state files written with permissions readable by other users + +## Verifying Releases + +Release archives ship with `checksums.txt`, an SPDX SBOM per archive, and signed +build provenance. Verify an archive with: -Thank you for helping keep git-scope secure! +```bash +gh attestation verify git-scope___.tar.gz --repo Bharath-code/git-scope +``` diff --git a/cmd/git-scope/main.go b/cmd/git-scope/main.go index c56a4dd..86150aa 100644 --- a/cmd/git-scope/main.go +++ b/cmd/git-scope/main.go @@ -2,16 +2,20 @@ package main import ( "bufio" + "context" "flag" "fmt" "log" "os" "path/filepath" "strings" + "time" "github.com/Bharath-code/git-scope/internal/browser" "github.com/Bharath-code/git-scope/internal/config" + "github.com/Bharath-code/git-scope/internal/mcpserver" "github.com/Bharath-code/git-scope/internal/nudge" + "github.com/Bharath-code/git-scope/internal/report" "github.com/Bharath-code/git-scope/internal/scan" "github.com/Bharath-code/git-scope/internal/tui" ) @@ -34,6 +38,8 @@ Usage: Commands: (default) Launch TUI dashboard scan Scan and print repos (JSON) + status Print repo state sorted by attention (--json) + mcp Run a read-only MCP server on stdio for AI agents scan-all Full system scan from home directory (with stats) init Create config file interactively issue Open git-scope GitHub issues page in browser @@ -44,6 +50,7 @@ Examples: git-scope ~/code ~/work # Scan specific directories git-scope scan . # Scan current directory (JSON) git-scope scan-all # Find ALL repos on your system + git-scope status --json # Attention-sorted repo state for scripts/agents git-scope init # Setup config interactively git-scope issue # Open GitHub issues page @@ -115,7 +122,7 @@ func parseCommand(args []string) (cmd string, dirs []string) { } switch args[0] { - case "scan", "tui", "help", "init", "scan-all", "issue": + case "scan", "status", "mcp", "tui", "help", "init", "scan-all", "issue": return args[0], args[1:] default: return "tui", args // assume it's a directory @@ -137,6 +144,11 @@ func run(cmd string, dirs []string, configPath string) error { return nil } + // --json is the only output format; accept it so the command reads naturally + if cmd == "status" { + dirs = without(dirs, "--json") + } + // Only commands below need config cfg, err := config.Load(configPath) if err != nil { @@ -160,6 +172,22 @@ func run(cmd string, dirs []string, configPath string) error { } return nil + case "status": + repos, err := scan.ScanRoots(cfg.Roots, cfg.Ignore) + if err != nil { + return fmt.Errorf("scan error: %w", err) + } + if err := report.PrintJSON(os.Stdout, report.Build(repos, time.Now())); err != nil { + return fmt.Errorf("print error: %w", err) + } + return nil + + case "mcp": + if err := mcpserver.Run(context.Background(), cfg.Roots, cfg.Ignore, version); err != nil { + return fmt.Errorf("mcp error: %w", err) + } + return nil + case "tui", "": if err := tui.Run(cfg); err != nil { return fmt.Errorf("tui error: %w", err) @@ -172,6 +200,17 @@ func run(cmd string, dirs []string, configPath string) error { } } +// without returns args minus every occurrence of flag. +func without(args []string, flag string) []string { + out := args[:0:0] + for _, a := range args { + if a != flag { + out = append(out, a) + } + } + return out +} + // expandDirs converts relative paths and ~ to absolute paths func expandDirs(dirs []string) []string { result := make([]string, 0, len(dirs)) diff --git a/go.mod b/go.mod index 16185b4..6b3d074 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/modelcontextprotocol/go-sdk v1.8.0 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.14.1 ) @@ -23,6 +24,7 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -31,7 +33,13 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.3.8 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index 7a43f92..927aa02 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,12 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -44,6 +48,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -54,16 +60,30 @@ 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.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= 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/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cache/cache.go b/internal/cache/cache.go index cde8976..7ebd401 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -71,7 +71,7 @@ func (s *FileStore) Save(repos []model.Repo, roots []string) error { // Ensure cache directory exists dir := filepath.Dir(s.path) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return err } @@ -80,7 +80,7 @@ func (s *FileStore) Save(repos []model.Repo, roots []string) error { return err } - return os.WriteFile(s.path, data, 0644) + return os.WriteFile(s.path, data, 0o600) } // IsValid checks if cache is still valid based on max age diff --git a/internal/config/config.go b/internal/config/config.go index 2feb97d..fb1f670 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -108,7 +108,7 @@ func ConfigExists(path string) bool { func CreateConfig(path string, roots []string, editor string) error { // Ensure directory exists dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("create config dir: %w", err) } @@ -134,7 +134,7 @@ func CreateConfig(path string, roots []string, editor string) error { // Add header comment content := "# git-scope configuration\n# Edit this file to customize scanning behavior\n\n" + string(data) - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { return fmt.Errorf("write config: %w", err) } diff --git a/internal/gitstatus/gitstatus.go b/internal/gitstatus/gitstatus.go index 748e46e..611413b 100644 --- a/internal/gitstatus/gitstatus.go +++ b/internal/gitstatus/gitstatus.go @@ -21,10 +21,14 @@ func Status(repoPath string) (model.RepoStatus, error) { return status, fmt.Errorf("git status: %w", err) } + hasUpstream := false for _, line := range strings.Split(string(out), "\n") { if line == "" { continue } + if strings.HasPrefix(line, "# branch.upstream ") { + hasUpstream = true + } // header lines -> branch metadata if strings.HasPrefix(line, "#") { @@ -36,6 +40,10 @@ func Status(repoPath string) (model.RepoStatus, error) { applyFileLine(&status, line) } + if !hasUpstream && status.Branch != "" { + status.Ahead = unpublishedCommits(repoPath) + } + status.IsDirty = status.Staged > 0 || status.Unstaged > 0 || status.Untracked > 0 || status.Ahead > 0 || status.Behind > 0 if t, err := lastCommitTime(repoPath); err == nil { @@ -59,9 +67,32 @@ func Command(ctx context.Context, dir string, args ...string) *exec.Cmd { return cmd } +// gitTimeout bounds each read so one hung repo (network mount, stuck lock) +// cannot stall a whole scan or a long-running MCP server. +const gitTimeout = 10 * time.Second + // runGit runs a git command in dir and returns its stdout. func runGit(dir string, args ...string) ([]byte, error) { - return Command(context.Background(), dir, args...).Output() + ctx, cancel := context.WithTimeout(context.Background(), gitTimeout) + defer cancel() + return Command(ctx, dir, args...).Output() +} + +// unpublishedCommits counts commits on HEAD that no remote-tracking ref +// contains — work on a never-pushed branch or a detached HEAD, which has no +// upstream and so no `branch.ab` line. Repos without remotes report 0: with +// nowhere to push, "unpushed" would flag every local-only project. +func unpublishedCommits(repoPath string) int { + remotes, err := runGit(repoPath, "remote") + if err != nil || len(strings.TrimSpace(string(remotes))) == 0 { + return 0 + } + out, err := runGit(repoPath, "rev-list", "--count", "HEAD", "--not", "--remotes") + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(string(out))) + return n } // applyBranchHeader parses porcelain v2 branch metadata lines and updates @@ -121,6 +152,10 @@ func applyFileLine(status *model.RepoStatus, line string) { status.Unstaged++ } + // u = unmerged (conflict) entry; count as unstaged work so it is never CLEAN + case strings.HasPrefix(line, "u "): + status.Unstaged++ + case strings.HasPrefix(line, "? "): status.Untracked++ } diff --git a/internal/gitstatus/gitstatus_test.go b/internal/gitstatus/gitstatus_test.go index b208fd5..2310748 100644 --- a/internal/gitstatus/gitstatus_test.go +++ b/internal/gitstatus/gitstatus_test.go @@ -193,3 +193,54 @@ func TestStatus_IgnoresRepoFsmonitor(t *testing.T) { t.Fatal("core.fsmonitor from repo config was executed") } } + +func TestApplyFileLine_ConflictCountsAsUnstaged(t *testing.T) { + var st model.RepoStatus + applyFileLine(&st, "u UU N... 100644 100644 100644 100644 aa bb cc f.go") + if st.Unstaged != 1 { + t.Errorf("Unstaged = %d, want 1 for a conflicted file", st.Unstaged) + } +} + +// Work with no upstream (a never-pushed agent branch, a detached HEAD) has no +// `branch.ab` line, so it must be counted against the remotes directly. +func TestStatus_UnpublishedWork(t *testing.T) { + origin := initRepoWithCommit(t) + clone := t.TempDir() + gitRun(t, clone, "clone", "-q", origin, ".") + + commit := func(name string) { + if err := os.WriteFile(filepath.Join(clone, name), []byte(name), 0644); err != nil { + t.Fatal(err) + } + gitRun(t, clone, "add", name) + gitRun(t, clone, "commit", "-qm", name) + } + + tests := []struct { + name string + setup func() + ahead int + }{ + {"tracked and in sync", func() {}, 0}, + {"new branch, never pushed", func() { gitRun(t, clone, "checkout", "-qb", "agent/x"); commit("a"); commit("b") }, 2}, + {"detached head", func() { gitRun(t, clone, "checkout", "-q", "--detach"); commit("c") }, 3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup() + st, err := Status(clone) + if err != nil { + t.Fatal(err) + } + if st.Ahead != tt.ahead { + t.Errorf("Ahead = %d, want %d", st.Ahead, tt.ahead) + } + }) + } + + // A repo with no remote has nowhere to push: never report unpushed. + if st, _ := Status(origin); st.Ahead != 0 { + t.Errorf("no-remote repo Ahead = %d, want 0", st.Ahead) + } +} diff --git a/internal/mcpserver/mcpserver.go b/internal/mcpserver/mcpserver.go new file mode 100644 index 0000000..7f7d9dd --- /dev/null +++ b/internal/mcpserver/mcpserver.go @@ -0,0 +1,118 @@ +// Package mcpserver exposes git-scope's repo state to AI coding agents over a +// stdio MCP server. Every tool is read-only and reuses the same scan → report +// path as `git-scope status --json`; nothing here touches the network. +package mcpserver + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/Bharath-code/git-scope/internal/attention" + "github.com/Bharath-code/git-scope/internal/report" + "github.com/Bharath-code/git-scope/internal/scan" +) + +// Repos is the tool output wrapper; MCP clients expect structured content to +// be a JSON object, so the array is nested under "repos". +type Repos struct { + Repos []report.Repo `json:"repos"` +} + +// PathInput selects one repository for repo_status. +type PathInput struct { + Path string `json:"path" jsonschema:"absolute path of a repository under the configured roots"` +} + +// Run serves the MCP tools on stdin/stdout until the client disconnects. +func Run(ctx context.Context, roots, ignore []string, version string) error { + return New(roots, ignore, version).Run(ctx, &mcp.StdioTransport{}) +} + +// New builds the server; split from Run so tests can use an in-memory transport. +func New(roots, ignore []string, version string) *mcp.Server { + statusFn := func() ([]report.Repo, error) { + repos, err := scan.ScanRoots(roots, ignore) + if err != nil { + return nil, fmt.Errorf("scan: %w", err) + } + return report.Build(repos, time.Now()), nil + } + + s := mcp.NewServer(&mcp.Implementation{Name: "git-scope", Version: version}, nil) + readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: new(bool)} + + mcp.AddTool(s, &mcp.Tool{ + Name: "list_repos", + Description: "List every git repository under the configured roots with branch, uncommitted file count, ahead/behind, unpushed commits, last commit time and an attention tier (ACTION/WATCH/CLEAN). Sorted most urgent first.", + Annotations: readOnly, + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, Repos, error) { + all, err := statusFn() + return nil, Repos{Repos: all}, err + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "needs_attention", + Description: "List only repositories with uncommitted, unpushed or out-of-sync work (tier ACTION or WATCH). Use this before ending a session to check nothing is left behind.", + Annotations: readOnly, + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, Repos, error) { + all, err := statusFn() + if err != nil { + return nil, Repos{}, err + } + out := []report.Repo{} + for _, r := range all { + if r.Tier != attention.Clean.String() { + out = append(out, r) + } + } + return nil, Repos{Repos: out}, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "repo_status", + Description: "Get the status of one repository by absolute path. The path must be a repository under git-scope's configured roots.", + Annotations: readOnly, + }, func(ctx context.Context, _ *mcp.CallToolRequest, in PathInput) (*mcp.CallToolResult, report.Repo, error) { + if in.Path == "" { + return nil, report.Repo{}, fmt.Errorf("path is required") + } + want := canonical(in.Path) + all, err := statusFn() + if err != nil { + return nil, report.Repo{}, err + } + // ponytail: full rescan per call; scan only the matching root if it gets slow. + for _, r := range all { + if canonical(r.Path) == want { + return nil, r, nil + } + } + return nil, report.Repo{}, fmt.Errorf("%s is not a repository under the configured roots", want) + }) + + return s +} + +// canonical makes agent-supplied and scanned paths comparable: it expands +// ~/, resolves relative paths and symlinks (macOS /tmp → /private/tmp), and +// strips trailing slashes. +func canonical(p string) string { + if rest, ok := strings.CutPrefix(p, "~/"); ok { + if home, err := os.UserHomeDir(); err == nil { + p = filepath.Join(home, rest) + } + } + if abs, err := filepath.Abs(p); err == nil { + p = abs + } + if real, err := filepath.EvalSymlinks(p); err == nil { + p = real + } + return filepath.Clean(p) +} diff --git a/internal/mcpserver/mcpserver_test.go b/internal/mcpserver/mcpserver_test.go new file mode 100644 index 0000000..24eaf9e --- /dev/null +++ b/internal/mcpserver/mcpserver_test.go @@ -0,0 +1,84 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestTools(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + root := t.TempDir() + for _, name := range []string{"clean", "dirty"} { + dir := filepath.Join(root, name) + if out, err := exec.Command("git", "init", "-q", dir).CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + } + if err := os.WriteFile(filepath.Join(root, "dirty", "f"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(filepath.Join(root, "dirty"), link); err != nil { + // Windows needs developer mode / admin for symlinks; test the plain path. + link = filepath.Join(root, "dirty") + } + + ctx := context.Background() + st, ct := mcp.NewInMemoryTransports() + if _, err := New([]string{root}, nil, "test").Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + cs, err := mcp.NewClient(&mcp.Implementation{Name: "t"}, nil).Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + call := func(name string, args map[string]any) (*mcp.CallToolResult, map[string]any) { + t.Helper() + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatal(err) + } + var out map[string]any + b, _ := json.Marshal(res.StructuredContent) + _ = json.Unmarshal(b, &out) + return res, out + } + + tests := []struct { + name string + tool string + args map[string]any + check func(map[string]any) bool + wantErr bool + }{ + {"list_repos returns all", "list_repos", nil, func(o map[string]any) bool { return len(o["repos"].([]any)) == 2 }, false}, + {"needs_attention drops clean", "needs_attention", nil, func(o map[string]any) bool { + r := o["repos"].([]any) + return len(r) == 1 && r[0].(map[string]any)["name"] == "dirty" + }, false}, + {"repo_status via symlink", "repo_status", map[string]any{"path": link + "/"}, func(o map[string]any) bool { return o["tier"] == "WATCH" }, false}, + {"repo_status outside roots", "repo_status", map[string]any{"path": t.TempDir()}, nil, true}, + {"repo_status empty path", "repo_status", map[string]any{"path": ""}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, out := call(tt.tool, tt.args) + if res.IsError != tt.wantErr { + t.Fatalf("IsError = %v, want %v: %+v", res.IsError, tt.wantErr, res.Content) + } + if tt.check != nil && !tt.check(out) { + t.Errorf("unexpected output: %v", out) + } + }) + } +} diff --git a/internal/nudge/nudge.go b/internal/nudge/nudge.go index e42959f..bc91057 100644 --- a/internal/nudge/nudge.go +++ b/internal/nudge/nudge.go @@ -58,7 +58,7 @@ func saveState(state *NudgeState) error { // Ensure directory exists dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return err } @@ -67,7 +67,7 @@ func saveState(state *NudgeState) error { return err } - return os.WriteFile(path, data, 0644) + return os.WriteFile(path, data, 0o600) } // ShouldShowNudge checks if the star nudge should be shown diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..31de3c9 --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,101 @@ +// Package report flattens scanned repos into the machine-readable shape shared +// by `git-scope status --json` and the MCP server, so agents see one schema. +package report + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/Bharath-code/git-scope/internal/attention" + "github.com/Bharath-code/git-scope/internal/model" +) + +// Repo is one repository's state as reported to scripts and agents. +type Repo struct { + Path string `json:"path" jsonschema:"absolute path of the repository"` + Name string `json:"name"` + Branch string `json:"branch"` + Dirty int `json:"dirty" jsonschema:"staged + unstaged + untracked file count"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Unpushed int `json:"unpushed" jsonschema:"local commits not on the upstream branch"` + LastCommit time.Time `json:"last_commit,omitzero" jsonschema:"omitted when the repo has no commits"` + Tier string `json:"tier" jsonschema:"ACTION, WATCH or CLEAN"` + Reason string `json:"reason"` + Error string `json:"error,omitempty"` +} + +// Build sorts repos by attention (most urgent first) and converts them. +func Build(repos []model.Repo, now time.Time) []Repo { + sorted := append([]model.Repo(nil), repos...) + sort.SliceStable(sorted, func(i, j int) bool { + return attention.Less(sorted[i], sorted[j], now) + }) + out := make([]Repo, 0, len(sorted)) + for _, r := range sorted { + s := r.Status + out = append(out, Repo{ + Path: r.Path, + Name: r.Name, + Branch: s.Branch, + Dirty: s.Staged + s.Unstaged + s.Untracked, + Ahead: s.Ahead, + Behind: s.Behind, + Unpushed: s.Ahead, + LastCommit: s.LastCommit, + Tier: attention.Classify(s, now).String(), + Reason: Reason(s, now), + Error: s.ScanError, + }) + } + return out +} + +// Reason explains the tier in a short phrase, listing signals in attention +// priority order (error, unpushed, behind, dirty, stale). +func Reason(s model.RepoStatus, now time.Time) string { + var parts []string + if s.ScanError != "" { + parts = append(parts, "scan error") + } + if s.Ahead > 0 { + parts = append(parts, fmt.Sprintf("%d unpushed commit%s", s.Ahead, plural(s.Ahead))) + } + if s.Behind > 0 { + parts = append(parts, fmt.Sprintf("%d behind upstream", s.Behind)) + } + if n := s.Staged + s.Unstaged + s.Untracked; n > 0 { + parts = append(parts, fmt.Sprintf("%d uncommitted file%s", n, plural(n))) + } + if attention.Stale(s, now) { + parts = append(parts, "stale") + } + if len(parts) == 0 { + return "clean" + } + return strings.Join(parts, ", ") +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +// PrintJSON writes repos as an indented JSON array (never null). +func PrintJSON(w io.Writer, repos []Repo) error { + if repos == nil { + repos = []Repo{} + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(repos); err != nil { + return fmt.Errorf("encode json: %w", err) + } + return nil +} diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 0000000..f041860 --- /dev/null +++ b/internal/report/report_test.go @@ -0,0 +1,90 @@ +package report + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/Bharath-code/git-scope/internal/model" +) + +func TestJSONShape(t *testing.T) { + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + recent := now.Add(-time.Hour) + old := now.Add(-30 * 24 * time.Hour) + + tests := []struct { + name string + repo model.Repo + want map[string]any + }{ + { + name: "clean", + repo: model.Repo{Name: "a", Path: "/r/a", Status: model.RepoStatus{Branch: "main", LastCommit: recent}}, + want: map[string]any{"path": "/r/a", "name": "a", "branch": "main", "dirty": 0.0, "ahead": 0.0, "behind": 0.0, + "unpushed": 0.0, "last_commit": recent.Format(time.RFC3339), "tier": "CLEAN", "reason": "clean"}, + }, + { + name: "unpushed", + repo: model.Repo{Name: "b", Path: "/r/b", Status: model.RepoStatus{Branch: "feat", Ahead: 2, IsDirty: true, LastCommit: recent}}, + want: map[string]any{"path": "/r/b", "name": "b", "branch": "feat", "dirty": 0.0, "ahead": 2.0, "behind": 0.0, + "unpushed": 2.0, "last_commit": recent.Format(time.RFC3339), "tier": "ACTION", "reason": "2 unpushed commits"}, + }, + { + name: "stale dirty", + repo: model.Repo{Name: "c", Path: "/r/c", Status: model.RepoStatus{Branch: "main", Unstaged: 1, Untracked: 2, IsDirty: true, LastCommit: old}}, + want: map[string]any{"path": "/r/c", "name": "c", "branch": "main", "dirty": 3.0, "ahead": 0.0, "behind": 0.0, + "unpushed": 0.0, "last_commit": old.Format(time.RFC3339), "tier": "WATCH", "reason": "3 uncommitted files, stale"}, + }, + { + name: "scan error", + repo: model.Repo{Name: "d", Path: "/r/d", Status: model.RepoStatus{ScanError: "boom"}}, + want: map[string]any{"path": "/r/d", "name": "d", "branch": "", "dirty": 0.0, "ahead": 0.0, "behind": 0.0, + "unpushed": 0.0, "tier": "ACTION", "reason": "scan error", "error": "boom"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + if err := PrintJSON(&buf, Build([]model.Repo{tt.repo}, now)); err != nil { + t.Fatal(err) + } + var got []map[string]any + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("not a JSON array: %v\n%s", err, buf.String()) + } + if len(got) != 1 { + t.Fatalf("got %d items, want 1", len(got)) + } + if len(got[0]) != len(tt.want) { + t.Errorf("keys = %v, want %v", got[0], tt.want) + } + for k, v := range tt.want { + if got[0][k] != v { + t.Errorf("%s = %v, want %v", k, got[0][k], v) + } + } + }) + } +} + +func TestBuildSortsByAttentionAndEmptyIsArray(t *testing.T) { + now := time.Now() + got := Build([]model.Repo{ + {Name: "clean"}, + {Name: "dirty", Status: model.RepoStatus{Unstaged: 1, IsDirty: true, LastCommit: now}}, + {Name: "ahead", Status: model.RepoStatus{Ahead: 1, IsDirty: true}}, + }, now) + if got[0].Name != "ahead" || got[1].Name != "dirty" || got[2].Name != "clean" { + t.Errorf("order = %s,%s,%s", got[0].Name, got[1].Name, got[2].Name) + } + + var buf bytes.Buffer + if err := PrintJSON(&buf, nil); err != nil { + t.Fatal(err) + } + if buf.String() != "[]\n" { + t.Errorf("empty = %q, want []", buf.String()) + } +} diff --git a/internal/scan/scan.go b/internal/scan/scan.go index 060cac0..f1dfab8 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -74,8 +74,8 @@ func ScanRoots(roots, ignore []string) ([]model.Repo, error) { return filepath.SkipDir } - // Found a .git directory - if d.IsDir() && d.Name() == ".git" { + // Found a .git directory, or a .git file (linked worktree, submodule) + if d.Name() == ".git" && (d.IsDir() || d.Type().IsRegular()) { repoPath := filepath.Dir(path) // Resolve to absolute path to get proper repo name @@ -101,7 +101,11 @@ func ScanRoots(roots, ignore []string) ([]model.Repo, error) { repos = append(repos, repo) mu.Unlock() - // Don't walk into .git directory + // Don't walk into .git directory. For a .git file SkipDir + // would skip the rest of the repo, hiding nested repos. + if !d.IsDir() { + return nil + } return filepath.SkipDir } diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go index 646d6e6..762998b 100644 --- a/internal/scan/scan_test.go +++ b/internal/scan/scan_test.go @@ -149,3 +149,26 @@ func TestScanRoots_NonexistentRootIsSkipped(t *testing.T) { t.Errorf("expected no repos for missing root, got %d", len(repos)) } } + +// Linked worktrees have a .git file, not a directory. Agents create them +// constantly, so they must be scanned like any other repo. +func TestScanRoots_FindsLinkedWorktree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + root := t.TempDir() + makeRepo(t, root, "main") + gitRun(t, filepath.Join(root, "main"), "worktree", "add", "-q", "-b", "agent", filepath.Join(root, "wt")) + + repos, err := ScanRoots([]string{root}, nil) + if err != nil { + t.Fatal(err) + } + found := map[string]string{} + for _, r := range repos { + found[r.Name] = r.Status.Branch + } + if found["wt"] != "agent" || found["main"] != "main" { + t.Errorf("repos = %v, want main@main and wt@agent", found) + } +}