Skip to content
Merged
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
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# gitsy

gitsy is a tiny CLI that scans a directory for git repositories and linked worktrees, automatically fetches from upstream, and updates local repos. It displays status in a nice TUI so you can see what needs attention.
gitsy is a tiny CLI that scans a directory for git repositories and linked worktrees, automatically fetches from upstream, and optionally fast-forwards local branches with `--sync`. It displays status in a nice TUI so you can see what needs attention.

![gitsy demo](assets/gitsy-demo.gif)

Expand Down Expand Up @@ -56,6 +56,22 @@ select rows, `f` to toggle file details,
`PgUp/PgDn` or the mouse wheel to scroll, and `q` to quit. Wide terminals
show context for the selected repository. Colors adapt to light and dark themes.

By default, each invocation runs `git fetch --all` for fresh upstream state;
nothing is cached between invocations. Within a run, main checkouts and linked
worktrees sharing a Git common directory share one fetch when safe. Separate
clones fetch independently, even with the same remote URL. Repositories with
worktree-specific configuration, conditional includes, submodules, relative
remote URLs, URL rewrites, custom fetch hooks, or custom transport/credential
commands fetch per checkout to preserve their existing behavior. Checkout-relative TLS, cookie,
validation, and bundle paths also disable sharing. Status and sync remain
independent for each checkout.

`--sync` always fetches, even with `--no-fetch`. Only clean branches with an
upstream, commits behind, and no commits ahead can fast-forward. If any remote
fails to fetch, sync is skipped for that checkout, including failures from
unrelated remotes. Available local status is shown as stale with
`sync skipped: fetch failed`.

Use `--non-interactive` for a status report: a brief progress line followed by
grouped checkout paths, branch status, change counts, and sync outcomes. It
includes clean repositories and linked worktrees, prints no filenames or terminal
Expand All @@ -72,8 +88,9 @@ paths resolve from the current directory.
Repositories are grouped by scan directory, in the order `--dir` is supplied.
Without `--dir`, the current directory is the group. Groups stay expanded;
navigation skips directory headers. Repositories found through overlapping
directories appear in each group, while fetch, status and sync run once per
unique repository. Linked worktrees appear in each group that discovers them.
directories appear in each group, while status and sync run once per unique
checkout. Fetches are shared across eligible linked worktrees as described above.
Linked worktrees appear in each group that discovers them.

The **Worktrees** tab lists linked worktrees from the same scan, including those
outside the supplied directories. Main checkouts remain in **Repositories**.
Expand Down
227 changes: 227 additions & 0 deletions cmd/gitsy/fetch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package main

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/flexdinesh/gitsy/internal/git"
)

func plainRemoteClone(t *testing.T, root, name string) (string, string) {
t.Helper()
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), "missing-config"))
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
remotes := t.TempDir()
origin, clone := filepath.Join(remotes, "origin"), filepath.Join(root, name)
plainGit(t, remotes, "init", "-q", "-b", "main", origin)
plainGit(t, origin, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "initial")
plainGit(t, root, "clone", "-q", origin, clone)
return origin, clone
}

func plainHead(t *testing.T, repo string) string {
t.Helper()
result := git.ResolveCommitContext(context.Background(), repo, "HEAD")
if !result.OK {
t.Fatalf("resolve HEAD: %s", result.Stderr)
}
return result.Stdout
}

func plainFetchCount(t *testing.T, trace string) int {
t.Helper()
data, err := os.ReadFile(trace)
if err != nil {
t.Fatal(err)
}
return strings.Count(string(data), "built-in: git fetch --all")
}

func TestPlainCommandSharesFetchWithinRunAndFetchesFreshNextRun(t *testing.T) {
root := t.TempDir()
origin, clone := plainRemoteClone(t, root, "clone")
linked := filepath.Join(t.TempDir(), "linked")
plainGit(t, clone, "worktree", "add", "-q", "-b", "feature", linked)
plainGit(t, linked, "branch", "--set-upstream-to=origin/main", "feature")
if err := os.WriteFile(filepath.Join(linked, "local.txt"), []byte("local"), 0o600); err != nil {
t.Fatal(err)
}
before := plainHead(t, linked)
trace := filepath.Join(t.TempDir(), "git.trace")
t.Setenv("GIT_TRACE", trace)
for invocation := 1; invocation <= 2; invocation++ {
plainGit(t, origin, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "update")
stdout, stderr, code := plainCommand(t, "--non-interactive", "--sync", "--dir", root, "--dir", root)
if code != 0 || stderr != "" || !strings.Contains(stdout, "synced ↓1") || !strings.Contains(stdout, "1 untracked") || !strings.Contains(stdout, linked) {
t.Fatalf("invocation %d must sync clean checkout and report dirty worktree: exit=%d stdout=%q stderr=%q", invocation, code, stdout, stderr)
}
if plainHead(t, clone) != plainHead(t, origin) || plainHead(t, linked) != before {
t.Fatalf("invocation %d must advance clean HEAD to latest upstream and preserve dirty HEAD", invocation)
}
if count := plainFetchCount(t, trace); count != invocation {
t.Fatalf("invocation %d: want one fresh fetch per run across worktrees and overlapping groups, total=%d", invocation, count)
}
}
}

func TestPlainCommandFetchesSeparateClonesOfSameRemote(t *testing.T) {
root := t.TempDir()
origin, first := plainRemoteClone(t, root, "first")
second := filepath.Join(root, "second")
plainGit(t, root, "clone", "-q", origin, second)
plainGit(t, origin, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "update")
trace := filepath.Join(t.TempDir(), "git.trace")
t.Setenv("GIT_TRACE", trace)
stdout, stderr, code := plainCommand(t, "--non-interactive", "--sync", "--dir", root)
if code != 0 || stderr != "" || strings.Count(stdout, "synced ↓1") != 2 {
t.Fatalf("independent clones must both sync: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
}
if plainHead(t, first) != plainHead(t, origin) || plainHead(t, second) != plainHead(t, origin) {
t.Fatal("each clone must fetch its own tracking refs before syncing")
}
if count := plainFetchCount(t, trace); count != 2 {
t.Fatalf("same remote URL must not share fetch across independent clones: count=%d", count)
}
}

func TestPlainCommandFailedSharedFetchSkipsSyncAgainstCachedUpstream(t *testing.T) {
root := t.TempDir()
origin, clone := plainRemoteClone(t, root, "clone")
linked := filepath.Join(t.TempDir(), "linked")
plainGit(t, clone, "worktree", "add", "-q", "-b", "feature", linked)
plainGit(t, linked, "branch", "--set-upstream-to=origin/main", "feature")
before := plainHead(t, clone)
plainGit(t, origin, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "update")
plainGit(t, clone, "fetch", "--all")
plainGit(t, clone, "remote", "set-url", "origin", filepath.Join(t.TempDir(), "missing-remote"))
trace := filepath.Join(t.TempDir(), "git.trace")
t.Setenv("GIT_TRACE", trace)
stdout, stderr, code := plainCommand(t, "--non-interactive", "--sync", "--verbose", "--dir", root)
if code != 1 || strings.Count(stdout, "sync skipped: fetch failed") != 2 || strings.Count(stdout, "⚠ stale") != 2 || !strings.Contains(stdout, "↓1") || !strings.Contains(stderr, "git operations failed") {
t.Fatalf("failed fetch must report stale cached status and skip both syncs: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
}
if plainHead(t, clone) != before || plainHead(t, linked) != before {
t.Fatal("failed fetch must preserve both HEADs despite cached fast-forwardable upstream")
}
if count := plainFetchCount(t, trace); count != 1 {
t.Fatalf("shared fetch failure must not trigger another attempt in this run: count=%d", count)
}
}

func TestPlainCommandFetchesEachWorktreeWhenSharingIsUnsafe(t *testing.T) {
for _, test := range []struct {
name string
configure func(*testing.T, string, string, string)
extra bool
}{
{
name: "worktree configuration",
configure: func(t *testing.T, clone, linked, extra string) {
plainGit(t, clone, "config", "extensions.worktreeConfig", "true")
plainGit(t, linked, "config", "--worktree", "remote.extra.url", extra)
plainGit(t, linked, "config", "--worktree", "remote.extra.fetch", "+refs/heads/*:refs/remotes/extra/*")
},
extra: true,
},
{
name: "conditional include",
configure: func(t *testing.T, clone, linked, extra string) {
included := filepath.Join(t.TempDir(), "feature.config")
plainGit(t, clone, "config", "--file", included, "remote.extra.url", extra)
plainGit(t, clone, "config", "--file", included, "remote.extra.fetch", "+refs/heads/*:refs/remotes/extra/*")
plainGit(t, clone, "config", "includeIf.onbranch:feature.path", included)
},
extra: true,
},
{
name: "submodule metadata",
configure: func(t *testing.T, clone, linked, extra string) {
if err := os.WriteFile(filepath.Join(clone, ".gitmodules"), nil, 0o600); err != nil {
t.Fatal(err)
}
},
},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
_, clone := plainRemoteClone(t, root, "clone")
linked := filepath.Join(t.TempDir(), "linked")
plainGit(t, clone, "worktree", "add", "-q", "-b", "feature", linked)
extra := filepath.Join(t.TempDir(), "extra")
plainGit(t, root, "init", "-q", "-b", "main", extra)
plainGit(t, extra, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "extra")
test.configure(t, clone, linked, extra)
trace := filepath.Join(t.TempDir(), "git.trace")
t.Setenv("GIT_TRACE", trace)
stdout, stderr, code := plainCommand(t, "--non-interactive", "--dir", root)
if code != 0 || stderr != "" || !strings.Contains(stdout, clone) || !strings.Contains(stdout, linked) {
t.Fatalf("checkout-specific fetching must succeed: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
}
if count := plainFetchCount(t, trace); count != 2 {
t.Fatalf("unsafe sharing must fetch both worktrees: count=%d", count)
}
if test.extra {
fetched := git.ResolveCommitContext(context.Background(), linked, "refs/remotes/extra/main")
if !fetched.OK || fetched.Stdout != plainHead(t, extra) {
t.Fatal("linked worktree's additional remote must be fetched")
}
}
})
}
}

func TestPlainCommandUnrelatedRemoteFailureSkipsSyncAfterUpstreamFetch(t *testing.T) {
root := t.TempDir()
origin, clone := plainRemoteClone(t, root, "clone")
linked := filepath.Join(t.TempDir(), "linked")
plainGit(t, clone, "worktree", "add", "-q", "-b", "feature", linked)
plainGit(t, linked, "branch", "--set-upstream-to=origin/main", "feature")
before := plainHead(t, clone)
plainGit(t, origin, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "update")
plainGit(t, clone, "remote", "add", "unrelated", filepath.Join(t.TempDir(), "missing-remote"))
trace := filepath.Join(t.TempDir(), "git.trace")
t.Setenv("GIT_TRACE", trace)
stdout, stderr, code := plainCommand(t, "--non-interactive", "--sync", "--dir", root)
if code != 1 || strings.Count(stdout, "sync skipped: fetch failed") != 2 || strings.Count(stdout, "⚠ stale") != 2 || !strings.Contains(stderr, "git operations failed") {
t.Fatalf("any remote failure must skip both syncs: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
}
fetched := git.ResolveCommitContext(context.Background(), clone, "refs/remotes/origin/main")
if !fetched.OK || fetched.Stdout != plainHead(t, origin) {
t.Fatal("successful origin fetch must still update shared upstream refs")
}
if plainHead(t, clone) != before || plainHead(t, linked) != before {
t.Fatal("unrelated remote failure must preserve both HEADs even with freshly fetched upstream")
}
if count := plainFetchCount(t, trace); count != 1 {
t.Fatalf("partial fetch failure must be shared across worktrees: count=%d", count)
}
}

func TestPlainCommandRelativeRemoteFetchesFromEachCheckout(t *testing.T) {
parentA, parentB := t.TempDir(), t.TempDir()
publisher, clone := plainRemoteClone(t, parentA, "clone")
origin := filepath.Join(parentA, "origin")
plainGit(t, parentA, "clone", "-q", "--bare", publisher, origin)
linked := filepath.Join(parentB, "linked")
plainGit(t, clone, "worktree", "add", "-q", "-b", "feature", linked)
plainGit(t, linked, "branch", "--set-upstream-to=origin/main", "feature")
plainGit(t, clone, "remote", "set-url", "origin", "../origin")
before := plainHead(t, linked)
plainGit(t, publisher, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "update")
plainGit(t, publisher, "push", "-q", origin, "main")
trace := filepath.Join(t.TempDir(), "git.trace")
t.Setenv("GIT_TRACE", trace)
stdout, stderr, code := plainCommand(t, "--non-interactive", "--sync", "--dir", parentA)
if code != 1 || strings.Count(stdout, "synced ↓1") != 1 || strings.Count(stdout, "sync skipped: fetch failed") != 1 || strings.Count(stdout, "⚠ stale") != 1 || !strings.Contains(stderr, "git operations failed") {
t.Fatalf("relative URL must resolve independently for each checkout: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
}
if plainHead(t, clone) != plainHead(t, publisher) || plainHead(t, linked) != before {
t.Fatal("main checkout must sync from its existing relative remote; linked checkout with missing relative remote must stay untouched")
}
if count := plainFetchCount(t, trace); count != 2 {
t.Fatalf("relative remote URL must prevent sharing across checkout directories: count=%d", count)
}
}
2 changes: 2 additions & 0 deletions internal/args/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const DefaultMaxDepth = 3
const Usage = `Usage: gitsy [options]

Show git status across child repositories and linked worktrees.
Fetch all remotes fresh every run; linked worktrees share fetching when safe.
Sync skips repos if fetching fails, including failures from unrelated remotes.

Options:
--max-depth <n> Scan repository directories up to n nested levels (default: 3)
Expand Down
118 changes: 118 additions & 0 deletions internal/git/fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package git

import (
"context"
"crypto/sha256"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)

// FetchKeyContext returns an empty key when checkout-specific fetch behavior
// cannot safely be shared with other worktrees.
func FetchKeyContext(ctx context.Context, repoPath string) string {
for _, name := range []string{"GIT_SSH", "GIT_SSH_COMMAND", "GIT_PROXY_COMMAND", "GIT_ASKPASS", "SSH_ASKPASS"} {
if os.Getenv(name) != "" {
return ""
}
}
for _, name := range []string{"GIT_SSL_CAINFO", "GIT_SSL_CAPATH", "GIT_SSL_CERT", "GIT_SSL_KEY"} {
if value := os.Getenv(name); value != "" && !filepath.IsAbs(value) {
return ""
}
}
common := RunContext(ctx, repoPath, "rev-parse", "--path-format=absolute", "--git-common-dir")
if !common.OK {
return ""
}
commonPath, err := filepath.EvalSymlinks(strings.TrimSuffix(common.Stdout, "\n"))
if err != nil {
return ""
}
config := RunContext(ctx, repoPath, "config", "--null", "--list", "--includes")
if !config.OK {
return ""
}
for _, entry := range strings.Split(config.Stdout, "\x00") {
key, value, _ := strings.Cut(entry, "\n")
key = strings.ToLower(key)
if checkoutFetchConfig(key, value) {
return ""
}
}
for _, path := range []string{
filepath.Join(repoPath, ".gitmodules"),
filepath.Join(commonPath, "modules"),
filepath.Join(commonPath, "hooks", "reference-transaction"),
filepath.Join(commonPath, "hooks", "pre-auto-gc"),
} {
if _, err := os.Lstat(path); !os.IsNotExist(err) {
return ""
}
}
modules := RunContext(ctx, repoPath, "ls-files", "-z", "--", ".gitmodules")
if !modules.OK || modules.Stdout != "" {
return ""
}
return fmt.Sprintf("%s\x00%x", commonPath, sha256.Sum256([]byte(config.Stdout)))
}

func checkoutFetchConfig(key, value string) bool {
if key == "extensions.worktreeconfig" || strings.HasPrefix(key, "includeif.") || strings.HasPrefix(key, "submodule.") {
return true
}
if key == "core.sshcommand" || key == "core.gitproxy" || key == "core.askpass" || key == "core.alternaterefscommand" || key == "core.hookspath" ||
strings.HasPrefix(key, "credential.") && strings.HasSuffix(key, ".helper") ||
strings.HasPrefix(key, "url.") && strings.HasSuffix(key, ".insteadof") {
return true
}
if (key == "fetch.fsck.skiplist" || key == "fsck.skiplist") && value != "" && !filepath.IsAbs(value) {
return true
}
if key == "fetch.bundleuri" && !checkoutIndependentURL(value) {
return true
}
if strings.HasPrefix(key, "remote.") {
if strings.HasSuffix(key, ".uploadpack") || strings.HasSuffix(key, ".vcs") {
return true
}
if strings.HasSuffix(key, ".url") {
return !checkoutIndependentURL(value)
}
}
if strings.HasPrefix(key, "http.") && value != "" && !filepath.IsAbs(value) {
for _, suffix := range []string{".sslcainfo", ".sslcapath", ".sslcert", ".sslkey", ".proxysslcainfo", ".proxysslcert", ".proxysslkey", ".cookiefile"} {
if strings.HasSuffix(key, suffix) {
return true
}
}
}
return false
}

func checkoutIndependentURL(value string) bool {
if strings.Contains(value, "::") {
return false
}
if strings.Contains(value, "://") {
parsed, err := url.Parse(value)
if err != nil {
return false
}
switch parsed.Scheme {
case "http", "https", "ssh", "git":
return true
case "file":
return filepath.IsAbs(parsed.Path)
default:
return false
}
}
if filepath.IsAbs(value) {
return true
}
colon, slash := strings.IndexByte(value, ':'), strings.IndexByte(value, '/')
return colon > 0 && (slash < 0 || colon < slash)
}
Loading
Loading