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
44 changes: 31 additions & 13 deletions DESIGN.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions cmd/gitsy/fetch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestPlainCommandSharesFetchWithinRunAndFetchesFreshNextRun(t *testing.T) {
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) {
if code != 0 || stderr != "" || !strings.Contains(stdout, "synced 1 commit") || !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 {
Expand All @@ -76,7 +76,7 @@ func TestPlainCommandFetchesSeparateClonesOfSameRemote(t *testing.T) {
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 {
if code != 0 || stderr != "" || strings.Count(stdout, "synced 1 commit") != 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) {
Expand Down Expand Up @@ -215,7 +215,7 @@ func TestPlainCommandRelativeRemoteFetchesFromEachCheckout(t *testing.T) {
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") {
if code != 1 || strings.Count(stdout, "synced 1 commit") != 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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/gitsy/plain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func TestPlainCommandSyncAlwaysFetchesAndReportsFinalStatus(t *testing.T) {
plainGit(t, root, "clone", "-q", origin, clone)
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", "--no-fetch", "--dir", root)
if code != 0 || stderr != "" || !strings.Contains(stdout, "synced ↓1") || strings.Contains(stdout, "1 behind") {
if code != 0 || stderr != "" || !strings.Contains(stdout, "synced 1 commit") || strings.Contains(stdout, "1 behind") {
t.Fatalf("sync must fetch and show final state: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
}
if git.ResolveCommitContext(context.Background(), clone, "HEAD").Stdout != git.ResolveCommitContext(context.Background(), origin, "HEAD").Stdout {
Expand Down
17 changes: 11 additions & 6 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ func TestDiscoversMultipleDirectoriesAndExternalWorktrees(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := []string{"linked", "project", "project"}
// The worktree has no remote, so it takes the main checkout's name.
want := []string{"project", "project", "project"}
wantPaths := []string{worktree, repo, filepath.Join(two, "project")}
if test.deep {
want = append(want, "deep")
Expand All @@ -154,8 +155,9 @@ func TestDiscoversMultipleDirectoriesAndExternalWorktrees(t *testing.T) {
}
for index := 1; index < len(repos); index++ {
previous, current := repos[index-1], repos[index]
if previous.DisplayName == current.DisplayName && previous.Path > current.Path {
t.Fatalf("duplicate names must be ordered by path: %v", repos)
sameKind := (previous.Worktree == nil) == (current.Worktree == nil)
if previous.DisplayName == current.DisplayName && (previous.Worktree != nil && current.Worktree == nil || sameKind && previous.Path > current.Path) {
t.Fatalf("duplicate names must list main checkouts first, then order by path: %v", repos)
}
}
})
Expand Down Expand Up @@ -254,7 +256,7 @@ func TestDiscoverNamesReposFromOrigin(t *testing.T) {

func TestDiscoverFallsBackForUnusableOrigin(t *testing.T) {
requireGit(t)
for _, origin := range []string{"", "https://example.com", "https://example.com/%zz"} {
for _, origin := range []string{"", "https://example.com", "https://example.com/%zz", "ext::sleep 120"} {
t.Run(origin, func(t *testing.T) {
dir := t.TempDir()
repo := filepath.Join(dir, "local-repo")
Expand Down Expand Up @@ -322,9 +324,12 @@ func TestDiscoversLinkedWorktreesFromDiscoveredRepo(t *testing.T) {
names := []string{}
for _, repo := range repos {
names = append(names, repo.DisplayName)
if repo.Path == worktree && repo.Worktree == nil {
t.Fatalf("linked worktree identity lost: %+v", repo)
}
}
if !reflect.DeepEqual(names, []string{"linked-worktree", "repo"}) {
t.Fatalf("expected linked-worktree and repo, got %#v", names)
if !reflect.DeepEqual(names, []string{"repo", "repo"}) {
t.Fatalf("linked worktree without a remote must use the main checkout name, got %#v", names)
}
}

Expand Down
4 changes: 4 additions & 0 deletions internal/args/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ Options:
--non-interactive Print a status report and exit without opening the TUI
--help Show this help message
--version Show package version

Worktrees tab keys:
x Delete the selected worktree; confirms before discarding files
X Force delete the selected worktree without confirmation
`

type Options struct {
Expand Down
9 changes: 9 additions & 0 deletions internal/args/args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package args

import (
"reflect"
"strings"
"testing"
)

Expand Down Expand Up @@ -99,3 +100,11 @@ func TestParseRejectsUnknownArgs(t *testing.T) {
}
}
}

func TestUsageDocumentsWorktreeKeys(t *testing.T) {
for _, want := range []string{" x ", " X ", "Force delete the selected worktree without confirmation"} {
if !strings.Contains(Usage, want) {
t.Fatalf("usage must document %q", want)
}
}
}
33 changes: 26 additions & 7 deletions internal/discover/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ func DiscoverGroupedContext(ctx context.Context, options Options) (Workspace, er
if err := ctx.Err(); err != nil {
return Workspace{}, err
}
verified, ok := verifyRepo(ctx, candidate, SourceScan, warn)
verified, ok := verifyRepo(ctx, candidate, SourceScan, DisplayNameForPath(candidate), warn)
if ok {
members[groupIndex][verified.RealPath] = true
if _, exists := reposByRealPath[verified.RealPath]; !exists {
Expand Down Expand Up @@ -238,14 +238,24 @@ func DiscoverGroupedContext(ctx context.Context, options Options) (Workspace, er
}

for worktreeIndex, worktree := range git.ParseWorktrees(result.Stdout) {
verified, ok := verifyRepo(ctx, worktree.Path, SourceWorktree, warn)
fallback := DisplayNameForPath(worktree.Path)
if worktreeIndex > 0 {
// Linked worktrees without a remote share the main checkout's name.
if name := strings.TrimSuffix(DisplayNameForPath(worktree.MainPath), ".git"); name != "" {
fallback = name
}
}
verified, ok := verifyRepo(ctx, worktree.Path, SourceWorktree, fallback, warn)
if ok {
for index, group := range scannedMembers {
if group[repo.RealPath] {
members[index][verified.RealPath] = true
}
}
if existing, exists := reposByRealPath[verified.RealPath]; exists {
if worktreeIndex > 0 {
existing.DisplayName = verified.DisplayName
}
verified = existing
}
if worktreeIndex > 0 {
Expand All @@ -263,11 +273,15 @@ func DiscoverGroupedContext(ctx context.Context, options Options) (Workspace, er
if err := ctx.Err(); err != nil {
return Workspace{}, err
}
// Linked worktrees follow the main checkout that shares their name.
sort.Slice(repos, func(i, j int) bool {
if repos[i].DisplayName == repos[j].DisplayName {
return repos[i].Path < repos[j].Path
if repos[i].DisplayName != repos[j].DisplayName {
return repos[i].DisplayName < repos[j].DisplayName
}
return repos[i].DisplayName < repos[j].DisplayName
if linkedI, linkedJ := repos[i].Worktree != nil, repos[j].Worktree != nil; linkedI != linkedJ {
return linkedJ
}
return repos[i].Path < repos[j].Path
})
groups := make([]Group, len(roots))
for index, root := range roots {
Expand Down Expand Up @@ -295,6 +309,10 @@ func displayPath(path string) string {
}

func nameFromRemote(remote string) string {
// Remote helpers such as ext:: carry commands, not repository paths.
if strings.Contains(remote, "::") {
return ""
}
if strings.Contains(remote, "://") {
parsed, err := url.Parse(remote)
if err != nil {
Expand All @@ -313,7 +331,8 @@ func nameFromRemote(remote string) string {
return displayPath(name)
}

func verifyRepo(ctx context.Context, repoPath string, source RepoSource, warn func(message string)) (Repo, bool) {
// verifyRepo names the repo from its origin remote, or fallbackName without one.
func verifyRepo(ctx context.Context, repoPath string, source RepoSource, fallbackName string, warn func(message string)) (Repo, bool) {
stats, err := os.Stat(repoPath)
if err != nil {
if os.IsNotExist(err) {
Expand Down Expand Up @@ -352,7 +371,7 @@ func verifyRepo(ctx context.Context, repoPath string, source RepoSource, warn fu
return Repo{}, false
}

displayName := DisplayNameForPath(repoPath)
displayName := fallbackName
if origin := git.OriginURLContext(ctx, repoPath); origin.OK {
if name := nameFromRemote(strings.TrimSuffix(origin.Stdout, "\x00")); name != "" {
displayName = name
Expand Down
2 changes: 2 additions & 0 deletions internal/discover/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ func TestNameFromRemote(t *testing.T) {
{name: "dot", remote: "."},
{name: "parent", remote: ".."},
{name: "invalid URL", remote: "https://example.com/%zz"},
{name: "ext helper", remote: "ext::sleep 120"},
{name: "helper with URL", remote: "codecommit::us-east-1://servediff"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
Expand Down
64 changes: 63 additions & 1 deletion internal/inspect/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Result struct {
Failed bool
Stale bool
Sync *SyncOutcome
Reason string // short explanation for failed, stale or skipped state
}

type gitCommands struct {
Expand Down Expand Up @@ -97,10 +98,12 @@ func repoContext(ctx context.Context, repo discover.Repo, noFetch bool, syncRepo
ctx = context.Background()
}
stale := false
reason := ""
if !noFetch || syncRepo {
fetchResult := commands.fetch(ctx, repo.Path, 30*time.Second)
fetchResult := commands.fetch(ctx, repo.Path, fetchTimeout)
if !fetchResult.OK {
stale = true
reason = fetchReason(fetchResult)
if warn != nil {
warn(fmt.Sprintf("Fetch failed for %s: %s", repo.DisplayName, gitError(fetchResult)))
}
Expand All @@ -122,6 +125,7 @@ func repoContext(ctx context.Context, repo discover.Repo, noFetch bool, syncRepo
Failed: true,
Stale: stale,
Sync: syncOutcome,
Reason: statusReason(statusResult),
}
}

Expand All @@ -131,6 +135,7 @@ func repoContext(ctx context.Context, repo discover.Repo, noFetch bool, syncRepo
Status: parsedStatus,
Stale: stale,
Sync: syncOutcome,
Reason: reason,
}
if syncRepo && stale {
return result
Expand All @@ -143,6 +148,7 @@ func repoContext(ctx context.Context, repo discover.Repo, noFetch bool, syncRepo
warn(fmt.Sprintf("Sync failed for %s: %s", repo.DisplayName, reason))
}
result.Sync = &SyncOutcome{Kind: "failed", Reason: reason}
result.Reason = "sync failed: " + shortMessage(reason)
return result
}
target := commands.resolve(ctx, repo.Path, "@{upstream}")
Expand Down Expand Up @@ -172,6 +178,7 @@ func repoContext(ctx context.Context, repo discover.Repo, noFetch bool, syncRepo
}
result.Failed = true
result.Sync = &SyncOutcome{Kind: "synced", Pulled: pulled}
result.Reason = statusReason(postStatus)
return result
}

Expand All @@ -182,6 +189,61 @@ func repoContext(ctx context.Context, repo discover.Repo, noFetch bool, syncRepo
return result
}

const fetchTimeout = 30 * time.Second

// fetchReason classifies fetch stderr into a short, stable phrase.
func fetchReason(result git.Result) string {
stderr := strings.ToLower(result.Stderr)
switch {
case strings.Contains(stderr, "timed out") && !strings.Contains(stderr, "connection timed out"):
return "fetch timed out after " + fetchTimeout.String()
case containsAny(stderr, "authentication failed", "permission denied", "could not read username", "could not read password", "terminal prompts disabled", "invalid username or password", "returned error: 401", "returned error: 403"):
return "fetch failed: auth"
case containsAny(stderr, "repository not found", "does not appear to be a git repository", "returned error: 404"):
return "fetch failed: repo not found"
case containsAny(stderr, "could not resolve host", "connection refused", "connection timed out", "network is unreachable", "no route to host", "failed to connect", "could not read from remote repository"):
return "remote unreachable"
case strings.Contains(stderr, "context canceled"):
return "fetch canceled"
}
return "fetch failed"
}

func statusReason(result git.Result) string {
if strings.Contains(result.Stderr, "timed out") {
return "status failed: timed out"
}
return "status failed: " + shortMessage(gitError(result))
}

// shortMessage keeps the first line of a Git error without its severity prefix.
func shortMessage(message string) string {
for _, line := range strings.Split(message, "\n") {
line = strings.TrimSpace(line)
for _, prefix := range []string{"fatal:", "error:"} {
line = strings.TrimSpace(strings.TrimPrefix(line, prefix))
}
if line != "" {
if runes := []rune(line); len(runes) > maxReasonRunes {
line = string(runes[:maxReasonRunes-1]) + "…"
}
return line
}
}
return "unknown error"
}

const maxReasonRunes = 48

func containsAny(value string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}

func gitError(result git.Result) string {
stderr := strings.TrimSpace(result.Stderr)
if stderr != "" {
Expand Down
Loading
Loading