Change name and prepare for push

This commit is contained in:
2026-07-28 18:03:43 +02:00
parent 42dcceaf4b
commit a0098a3946
9 changed files with 95 additions and 40 deletions

View File

@@ -1,4 +1,4 @@
# gh-threads # diple
A terminal UI for people receiving GitHub pull-request reviews. It A terminal UI for people receiving GitHub pull-request reviews. It
shows open PRs and a scrollable PR dashboard with the description, branches, shows open PRs and a scrollable PR dashboard with the description, branches,
@@ -20,7 +20,7 @@ Requires Go 1.24+, Git 2.38+, and an authenticated GitHub CLI:
```sh ```sh
go install . go install .
gh auth login gh auth login
gh-threads diple
``` ```
To run directly from a source checkout instead: To run directly from a source checkout instead:
@@ -41,34 +41,38 @@ authenticated user and groups the results by repository. Use `--repo` to limit
the picker to one repository: the picker to one repository:
```sh ```sh
gh-threads --repo owner/repository diple --repo owner/repository
``` ```
With a repository selected, pass `--all` to include every open PR in that With a repository selected, pass `--all` to include every open PR in that
repository: repository:
```sh ```sh
gh-threads --repo owner/repository --all --poll 15s diple --repo owner/repository --all --poll 15s
``` ```
GitHub Enterprise Server can be used after authenticating that host: GitHub Enterprise Server can be used after authenticating that host:
```sh ```sh
gh auth login --hostname github.example.com gh auth login --hostname github.example.com
gh-threads --repo owner/repository \ diple --repo owner/repository \
--endpoint https://github.example.com/api/graphql --endpoint https://github.example.com/api/graphql
``` ```
## Configuration ## Configuration
The optional TOML configuration is loaded from The optional TOML configuration is loaded from
`$GH_THREADS_CONFIG`, `$XDG_CONFIG_HOME/gh-threads/config.toml`, or the `$DIPLE_CONFIG`, `$XDG_CONFIG_HOME/diple/config.toml`, or the operating
operating system's user configuration directory at `gh-threads/config.toml`. system's user configuration directory at `diple/config.toml`.
On Linux this is normally `~/.config/gh-threads/config.toml`. On macOS, On Linux this is normally `~/.config/diple/config.toml`. On macOS,
`~/Library/Application Support/gh-threads/config.toml` is preferred, with `~/Library/Application Support/diple/config.toml` is preferred, with
`~/.config/gh-threads/config.toml` automatically used as a fallback when it `~/.config/diple/config.toml` automatically used as a fallback when it
exists. exists.
For migration, `GH_THREADS_CONFIG` and existing `gh-threads` configuration or
cache directories remain fallback locations when their new `diple`
counterparts do not yet exist.
```toml ```toml
theme = "dark" # dark, light, high-contrast, or no-color theme = "dark" # dark, light, high-contrast, or no-color
refresh_interval = "10s" refresh_interval = "10s"
@@ -221,7 +225,7 @@ avoid synchronized clients. Opening another PR or starting another refresh
cancels the superseded request. cancels the superseded request.
GitHub's public APIs report whether a PR conflicts but do not expose its GitHub's public APIs report whether a PR conflicts but do not expose its
conflicting file paths. For conflicting PRs only, `gh-threads` performs a conflicting file paths. For conflicting PRs only, `diple` performs a
read-only `git merge-tree` analysis in a temporary bare repository. It never read-only `git merge-tree` analysis in a temporary bare repository. It never
touches or inspects the current checkout, so Git, Jujutsu (`jj`), and directories touches or inspects the current checkout, so Git, Jujutsu (`jj`), and directories
without a local repository behave identically. The analysis fetches the exact without a local repository behave identically. The analysis fetches the exact

View File

@@ -96,31 +96,46 @@ func defaultConfig() Config {
} }
func configPath() (string, error) { func configPath() (string, error) {
if path := os.Getenv("DIPLE_CONFIG"); path != "" {
return path, nil
}
// Preserve the old override during the rename so existing scripts do not
// silently start with a fresh configuration.
if path := os.Getenv("GH_THREADS_CONFIG"); path != "" { if path := os.Getenv("GH_THREADS_CONFIG"); path != "" {
return path, nil return path, nil
} }
if base := os.Getenv("XDG_CONFIG_HOME"); base != "" { if base := os.Getenv("XDG_CONFIG_HOME"); base != "" {
return filepath.Join(base, "gh-threads", "config.toml"), nil return firstExistingOrDefault(
filepath.Join(base, "diple", "config.toml"),
filepath.Join(base, "gh-threads", "config.toml"),
), nil
} }
base, err := os.UserConfigDir() base, err := os.UserConfigDir()
if err != nil { if err != nil {
return "", fmt.Errorf("find user config directory: %w", err) return "", fmt.Errorf("find user config directory: %w", err)
} }
preferred := filepath.Join(base, "gh-threads", "config.toml") preferred := filepath.Join(base, "diple", "config.toml")
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", fmt.Errorf("find home directory: %w", err) return "", fmt.Errorf("find home directory: %w", err)
} }
fallback := filepath.Join(home, ".config", "gh-threads", "config.toml") dotConfig := filepath.Join(home, ".config", "diple", "config.toml")
return existingConfigPath(preferred, fallback), nil legacyPreferred := filepath.Join(base, "gh-threads", "config.toml")
legacyDotConfig := filepath.Join(home, ".config", "gh-threads", "config.toml")
return firstExistingOrDefault(
preferred, dotConfig, legacyPreferred, legacyDotConfig,
), nil
} }
func existingConfigPath(preferred, fallback string) string { func existingConfigPath(preferred, fallback string) string {
if _, err := os.Stat(preferred); err == nil || !errors.Is(err, os.ErrNotExist) { return firstExistingOrDefault(preferred, fallback)
return preferred }
}
if _, err := os.Stat(fallback); err == nil { func firstExistingOrDefault(preferred string, alternatives ...string) string {
return fallback for _, candidate := range append([]string{preferred}, alternatives...) {
if _, err := os.Stat(candidate); err == nil || !errors.Is(err, os.ErrNotExist) {
return candidate
}
} }
return preferred return preferred
} }
@@ -200,7 +215,10 @@ func defaultCacheDir() (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("find user cache directory: %w", err) return "", fmt.Errorf("find user cache directory: %w", err)
} }
return filepath.Join(base, "gh-threads"), nil return firstExistingOrDefault(
filepath.Join(base, "diple"),
filepath.Join(base, "gh-threads"),
), nil
} }
func validateThreadStatusOrder(order []string) error { func validateThreadStatusOrder(order []string) error {

View File

@@ -52,7 +52,7 @@ within_status = "timestamp"
[cache] [cache]
enabled = false enabled = false
max_age = "48h" max_age = "48h"
directory = "/tmp/gh-threads-cache" directory = "/tmp/diple-cache"
[editing] [editing]
mode = "standard" mode = "standard"
@@ -79,7 +79,7 @@ up = ["ctrl+k"]
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond || !got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled || got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" || got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/diple-cache" ||
got.Editing.Mode != "standard" || got.Editing.Mode != "standard" ||
strings.Join(got.KeyBindings.Navigation.Down, ",") != "ctrl+j" || strings.Join(got.KeyBindings.Navigation.Down, ",") != "ctrl+j" ||
strings.Join(got.KeyBindings.Navigation.Up, ",") != "ctrl+k" { strings.Join(got.KeyBindings.Navigation.Up, ",") != "ctrl+k" {
@@ -149,20 +149,33 @@ func TestLoadConfigRejectsUnknownSettings(t *testing.T) {
} }
func TestConfigPathHonorsEnvironmentOverride(t *testing.T) { func TestConfigPathHonorsEnvironmentOverride(t *testing.T) {
t.Setenv("GH_THREADS_CONFIG", "/tmp/custom-gh-threads.toml") t.Setenv("DIPLE_CONFIG", "/tmp/custom-diple.toml")
t.Setenv("GH_THREADS_CONFIG", "")
got, err := configPath() got, err := configPath()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got != "/tmp/custom-gh-threads.toml" { if got != "/tmp/custom-diple.toml" {
t.Fatalf("config path = %q", got) t.Fatalf("config path = %q", got)
} }
} }
func TestConfigPathHonorsLegacyEnvironmentOverride(t *testing.T) {
t.Setenv("DIPLE_CONFIG", "")
t.Setenv("GH_THREADS_CONFIG", "/tmp/legacy-gh-threads.toml")
got, err := configPath()
if err != nil {
t.Fatal(err)
}
if got != "/tmp/legacy-gh-threads.toml" {
t.Fatalf("legacy config path = %q", got)
}
}
func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) { func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
root := t.TempDir() root := t.TempDir()
preferred := filepath.Join(root, "Library", "Application Support", "gh-threads", "config.toml") preferred := filepath.Join(root, "Library", "Application Support", "diple", "config.toml")
fallback := filepath.Join(root, ".config", "gh-threads", "config.toml") fallback := filepath.Join(root, ".config", "diple", "config.toml")
if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -184,14 +197,30 @@ func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
} }
} }
func TestFirstExistingConfigPathFallsBackToLegacyName(t *testing.T) {
root := t.TempDir()
current := filepath.Join(root, "diple", "config.toml")
legacy := filepath.Join(root, "gh-threads", "config.toml")
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, []byte("theme = \"dark\"\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := firstExistingOrDefault(current, legacy); got != legacy {
t.Fatalf("migration config path = %q, want %q", got, legacy)
}
}
func TestConfigPathHonorsXDGConfigHome(t *testing.T) { func TestConfigPathHonorsXDGConfigHome(t *testing.T) {
t.Setenv("GH_THREADS_CONFIG", "") t.Setenv("GH_THREADS_CONFIG", "")
t.Setenv("DIPLE_CONFIG", "")
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config") t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
got, err := configPath() got, err := configPath()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := "/tmp/xdg-config/gh-threads/config.toml" want := "/tmp/xdg-config/diple/config.toml"
if got != want { if got != want {
t.Fatalf("config path = %q, want %q", got, want) t.Fatalf("config path = %q, want %q", got, want)
} }

View File

@@ -57,7 +57,7 @@ func analyzeConflictFiles(
if repositoryURL == "" || baseRef == "" || number <= 0 { if repositoryURL == "" || baseRef == "" || number <= 0 {
return nil, errors.New("missing repository merge metadata") return nil, errors.New("missing repository merge metadata")
} }
gitDir, err := os.MkdirTemp("", "gh-threads-conflicts-*") gitDir, err := os.MkdirTemp("", "diple-conflicts-*")
if err != nil { if err != nil {
return nil, fmt.Errorf("create temporary merge repository: %w", err) return nil, fmt.Errorf("create temporary merge repository: %w", err)
} }
@@ -83,8 +83,8 @@ func analyzeConflictFiles(
} }
refspecs := []string{ refspecs := []string{
"+refs/heads/" + baseRef + ":refs/gh-threads/base", "+refs/heads/" + baseRef + ":refs/diple/base",
"+refs/pull/" + strconv.Itoa(number) + "/head:refs/gh-threads/head", "+refs/pull/" + strconv.Itoa(number) + "/head:refs/diple/head",
} }
fetch := func(depthArgs ...string) error { fetch := func(depthArgs ...string) error {
args := []string{"-C", gitDir, "fetch", "--quiet", "--no-tags", "--filter=blob:none"} args := []string{"-C", gitDir, "fetch", "--quiet", "--no-tags", "--filter=blob:none"}
@@ -113,7 +113,7 @@ func analyzeConflictFiles(
output, runErr := run( output, runErr := run(
"-C", gitDir, "merge-tree", "--write-tree", "--name-only", "--no-messages", "-z", "-C", gitDir, "merge-tree", "--write-tree", "--name-only", "--no-messages", "-z",
"refs/gh-threads/base", "refs/gh-threads/head", "refs/diple/base", "refs/diple/head",
) )
if runErr == nil { if runErr == nil {
return nil, nil return nil, nil
@@ -130,7 +130,7 @@ func mergeBaseExists(
gitDir string, gitDir string,
) bool { ) bool {
_, err := run( _, err := run(
"-C", gitDir, "merge-base", "refs/gh-threads/base", "refs/gh-threads/head", "-C", gitDir, "merge-base", "refs/diple/base", "refs/diple/head",
) )
return err == nil return err == nil
} }

View File

@@ -105,7 +105,7 @@ func (c *GitHubClient) query(
} }
req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "gh-threads") req.Header.Set("User-Agent", "diple")
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {

2
go.mod
View File

@@ -1,4 +1,4 @@
module git.pablu.de/Pablu/gh-threads module git.pablu.de/Pablu/diple
go 1.24.0 go 1.24.0

View File

@@ -39,7 +39,11 @@ func main() {
visited := map[string]bool{} visited := map[string]bool{}
flag.Visit(func(item *flag.Flag) { visited[item.Name] = true }) flag.Visit(func(item *flag.Flag) { visited[item.Name] = true })
config, err := loadConfig(*configFile, visited["config"] || os.Getenv("GH_THREADS_CONFIG") != "") config, err := loadConfig(
*configFile,
visited["config"] || os.Getenv("DIPLE_CONFIG") != "" ||
os.Getenv("GH_THREADS_CONFIG") != "",
)
if err != nil { if err != nil {
exitf("configuration: %v", err) exitf("configuration: %v", err)
} }
@@ -167,7 +171,7 @@ func firstNonEmpty(values ...string) string {
} }
func exitf(format string, args ...any) { func exitf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "gh-threads: "+format+"\n", args...) fmt.Fprintf(os.Stderr, "diple: "+format+"\n", args...)
os.Exit(1) os.Exit(1)
} }

View File

@@ -19,7 +19,7 @@ func atomicWriteJSON(path string, value any, mode os.FileMode) error {
if err != nil { if err != nil {
return err return err
} }
temp, err := os.CreateTemp(dir, ".gh-threads-*") temp, err := os.CreateTemp(dir, ".diple-*")
if err != nil { if err != nil {
return err return err
} }

4
tui.go
View File

@@ -1802,7 +1802,7 @@ func (m App) viewWritePopup() string {
strings.ToLower(m.mergeMethod), strings.ToLower(m.mergeMethod),
)), )),
"", "",
badStyle.Render("This action cannot be undone from gh-threads."), badStyle.Render("This action cannot be undone from diple."),
"", "",
warnStyle.Render(fmt.Sprintf( warnStyle.Render(fmt.Sprintf(
"%s merge now • %s cancel", "%s merge now • %s cancel",
@@ -2126,7 +2126,7 @@ func paneStyle(active bool) lipgloss.Style {
} }
func (m App) viewPRs() string { func (m App) viewPRs() string {
header := titleStyle.Render("gh-threads") header := titleStyle.Render("diple")
if m.owner != "" { if m.owner != "" {
header += " " + m.owner + "/" + m.repo header += " " + m.owner + "/" + m.repo
} }