Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Opt-in support for linked git worktrees ([#24](https://github.com/Bharath-code/git-scope/issues/24)).
- Config: `includeWorktrees: false` (default) in `~/.config/git-scope/config.yml`.
- CLI flag: `--worktrees` for one-shot enable.
- TUI: press `W` to toggle live; the toggle controls both visibility and totals.
- Scan/JSON: each repo carries an `is_worktree` field; worktrees show a `⎇` marker in the TUI table.
- Submodules are excluded — only `gitdir:` pointers under `.git/worktrees/` are recognised.

6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Typical git workflows involve "tunnel vision"—working deep inside one reposito
| `g` | Toggle **Contribution Graph** |
| `d` | Toggle **Disk Usage** view |
| `t` | Toggle **Timeline** view |
| `W` | Toggle **Worktree inclusion** (rescan; affects totals too) |
| `q` | Quit |

-----
Expand All @@ -172,6 +173,11 @@ ignore:
- dist

editor: code # options: code,nvim,lazygit,vim,cursor

# Linked git worktrees are skipped by default. Set this to true (or pass
# --worktrees on the CLI, or press W in the TUI) to include them in the
# dashboard and stats.
includeWorktrees: false
```

-----
Expand Down
47 changes: 36 additions & 11 deletions cmd/git-scope/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import (
const version = "1.0.1"

type options struct {
ConfigPath string
ShowVersion bool
ShowHelp bool
ConfigPath string
ShowVersion bool
ShowHelp bool
IncludeWorktrees bool
WorktreesSet bool
}

func usage() {
Expand Down Expand Up @@ -74,7 +76,7 @@ func main() {
return
}

if err := run(cmd, dirs, opts.ConfigPath); err != nil {
if err := run(cmd, dirs, opts); err != nil {
log.Fatal(err)
}
}
Expand All @@ -95,12 +97,24 @@ func parseFlags() options {
flag.BoolVar(&showHelp, "h", false, "Help")
flag.BoolVar(&showHelp, "help", false, "Help")

var includeWorktrees bool
flag.BoolVar(&includeWorktrees, "worktrees", false, "Include linked git worktrees in scan results")

flag.Parse()

worktreesSet := false
flag.Visit(func(f *flag.Flag) {
if f.Name == "worktrees" {
worktreesSet = true
}
})

return options{
ConfigPath: *configPath,
ShowVersion: showVersion,
ShowHelp: showHelp,
ConfigPath: *configPath,
ShowVersion: showVersion,
ShowHelp: showHelp,
IncludeWorktrees: includeWorktrees,
WorktreesSet: worktreesSet,
}
}

Expand All @@ -121,7 +135,7 @@ func parseCommand(args []string) (cmd string, dirs []string) {

// run executes the requested command using the provided configuration path
// and directories.
func run(cmd string, dirs []string, configPath string) error {
func run(cmd string, dirs []string, opts options) error {
switch cmd {
case "init":
runInit()
Expand All @@ -135,20 +149,31 @@ func run(cmd string, dirs []string, configPath string) error {
}

// Only commands below need config
cfg, err := config.Load(configPath)
cfg, err := config.Load(opts.ConfigPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

if len(dirs) > 0 {
cfg.Roots = expandDirs(dirs)
} else if !config.ConfigExists(configPath) {
} else if !config.ConfigExists(opts.ConfigPath) {
cfg.Roots = getSmartDefaults()
}

// Resolution order for IncludeWorktrees, low → high precedence:
// 1. Config file (`includeWorktrees: ...`)
// 2. State file (`~/.config/git-scope/state.json`) — runtime W toggle
// 3. CLI flag (`--worktrees`)
if state, err := config.LoadState(config.DefaultStatePath()); err == nil {
cfg.IncludeWorktrees = state.IncludeWorktrees
}
if opts.WorktreesSet {
cfg.IncludeWorktrees = opts.IncludeWorktrees
}

switch cmd {
case "scan":
repos, err := scan.ScanRoots(cfg.Roots, cfg.Ignore)
repos, err := scan.ScanRootsWithOptions(cfg.Roots, cfg.Ignore, cfg.IncludeWorktrees)
if err != nil {
return fmt.Errorf("scan error: %w", err)
}
Expand Down
27 changes: 19 additions & 8 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ import (

// CacheData represents the cached scan results
type CacheData struct {
Repos []model.Repo `json:"repos"`
Timestamp time.Time `json:"timestamp"`
Roots []string `json:"roots"`
Repos []model.Repo `json:"repos"`
Timestamp time.Time `json:"timestamp"`
Roots []string `json:"roots"`
IncludeWorktrees bool `json:"include_worktrees,omitempty"`
}

// Store interface for caching repo data
type Store interface {
Load() (*CacheData, error)
Save(repos []model.Repo, roots []string) error
Save(repos []model.Repo, roots []string, includeWorktrees bool) error
IsValid(maxAge time.Duration) bool
}

Expand Down Expand Up @@ -62,11 +63,12 @@ func (s *FileStore) Load() (*CacheData, error) {
}

// Save writes repos to cache file
func (s *FileStore) Save(repos []model.Repo, roots []string) error {
func (s *FileStore) Save(repos []model.Repo, roots []string, includeWorktrees bool) error {
cache := CacheData{
Repos: repos,
Timestamp: time.Now(),
Roots: roots,
Repos: repos,
Timestamp: time.Now(),
Roots: roots,
IncludeWorktrees: includeWorktrees,
}

// Ensure cache directory exists
Expand Down Expand Up @@ -104,6 +106,15 @@ func (s *FileStore) IsSameRoots(roots []string) bool {
return true
}

// IsSameIncludeWorktrees checks whether the cache was produced with the same
// worktree-inclusion setting. Toggling forces a rescan.
func (s *FileStore) IsSameIncludeWorktrees(includeWorktrees bool) bool {
if s.data == nil {
return false
}
return s.data.IncludeWorktrees == includeWorktrees
}

// GetTimestamp returns the cache timestamp
func (s *FileStore) GetTimestamp() time.Time {
if s.data == nil {
Expand Down
59 changes: 55 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand All @@ -11,10 +12,11 @@ import (

// Config holds the application configuration
type Config struct {
Roots []string `yaml:"roots"`
Ignore []string `yaml:"ignore"`
Editor string `yaml:"editor"`
PageSize int `yaml:"pageSize,omitempty"`
Roots []string `yaml:"roots"`
Ignore []string `yaml:"ignore"`
Editor string `yaml:"editor"`
PageSize int `yaml:"pageSize,omitempty"`
IncludeWorktrees bool `yaml:"includeWorktrees,omitempty"`
}

// defaultConfig returns sensible defaults
Expand Down Expand Up @@ -105,6 +107,55 @@ func DefaultConfigPath() string {
return filepath.Join(home, ".config", "git-scope", "config.yml")
}

// State holds user-toggled preferences that should persist across runs but
// don't belong in the human-edited YAML config (and would clobber its
// comments on rewrite).
type State struct {
IncludeWorktrees bool `json:"include_worktrees"`
}

// DefaultStatePath returns the default location for the state file.
func DefaultStatePath() string {
home, err := os.UserHomeDir()
if err != nil {
return "./state.json"
}
return filepath.Join(home, ".config", "git-scope", "state.json")
}

// LoadState reads the persisted state file. Returns a zero-value State and
// no error when the file is missing — first-run is not an error.
func LoadState(path string) (State, error) {
var s State
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return s, fmt.Errorf("read state: %w", err)
}
if err := json.Unmarshal(data, &s); err != nil {
return s, fmt.Errorf("parse state: %w", err)
}
return s, nil
}

// SaveState writes the state file, creating the parent directory as needed.
func SaveState(path string, s State) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create state dir: %w", err)
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write state: %w", err)
}
return nil
}

// ConfigExists checks if a config file exists at the given path
func ConfigExists(path string) bool {
_, err := os.Stat(path)
Expand Down
7 changes: 4 additions & 3 deletions internal/model/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ type RepoStatus struct {

// Repo represents a git repository with its metadata and status
type Repo struct {
Name string `json:"name"`
Path string `json:"path"`
Status RepoStatus `json:"status"`
Name string `json:"name"`
Path string `json:"path"`
Status RepoStatus `json:"status"`
IsWorktree bool `json:"is_worktree,omitempty"`
}
Loading