From 29f62da006eea31861f769c05e0e2b47bfc27138 Mon Sep 17 00:00:00 2001 From: bircni Date: Mon, 12 Oct 2026 10:57:39 +0200 Subject: [PATCH] refactor: load settings once per run and reuse shared helpers The application caches the settings snapshot and reloads it only after an update through the store, so output and prompt helpers no longer read and parse config.yml and hosts.yml several times per command. canPrompt and chooser replace the inline prompt-policy checks. Checks are tallied by one services.Tally, terminal.Plural replaces the two app copies, refKind.article/noun replace the comment group's own words, list commands use servicePage, and web URLs are built by repositoryURL and webURL in browser.go. Signed-off-by: bircni --- internal/app/action_jobs.go | 2 +- internal/app/action_runs.go | 16 +++---------- internal/app/action_values.go | 6 +---- internal/app/actions.go | 10 ++++---- internal/app/admin_users.go | 6 +---- internal/app/app.go | 24 ++++++++++++++++++- internal/app/artifacts.go | 2 +- internal/app/auth.go | 18 ++++++-------- internal/app/branches.go | 6 ++--- internal/app/browser.go | 15 ++++++++++++ internal/app/checkout.go | 2 +- internal/app/checks.go | 40 ++++++------------------------- internal/app/comments.go | 24 +++++++------------ internal/app/completion.go | 8 +++---- internal/app/config.go | 8 +++---- internal/app/discussion_view.go | 23 +++--------------- internal/app/doctor.go | 2 +- internal/app/git.go | 2 +- internal/app/git_credentials.go | 2 +- internal/app/human_output.go | 2 +- internal/app/issue_attachments.go | 4 ++-- internal/app/issues.go | 4 ++-- internal/app/metadata.go | 2 +- internal/app/notifications.go | 6 ++--- internal/app/organizations.go | 10 -------- internal/app/prompts.go | 16 +++++++++++-- internal/app/pull_clean.go | 2 +- internal/app/pull_create.go | 4 ++-- internal/app/pull_review.go | 6 ----- internal/app/pulls.go | 7 +----- internal/app/ref.go | 2 +- internal/app/releases.go | 2 +- internal/app/repos.go | 14 +++-------- internal/app/repository_create.go | 2 +- internal/app/repository_sync.go | 2 +- internal/app/search.go | 2 +- internal/app/target.go | 2 +- internal/app/webhooks.go | 6 +---- internal/app/wiki.go | 10 +++----- internal/services/checks.go | 27 +++++++++++++-------- internal/settings/store.go | 8 +++++++ internal/terminal/time.go | 5 ++-- 42 files changed, 157 insertions(+), 204 deletions(-) diff --git a/internal/app/action_jobs.go b/internal/app/action_jobs.go index e4d8aef6..11a9eda8 100644 --- a/internal/app/action_jobs.go +++ b/internal/app/action_jobs.go @@ -34,7 +34,7 @@ $ tea run jobs 34 --json id,conclusion --jq '.[] | select(.conclusion == "failur return err } spec := listSpec[services.Job]{Columns: jobColumns, Row: jobRow(a.deps.Now()), Empty: fmt.Sprintf("no jobs in run %d", run), Context: fmt.Sprintf("jobs in run %d", run)} - return writeTable(a, cmd, jobs.Items, spec, listPage{Number: page, More: jobs.More, Total: jobs.Total, Counted: jobs.Counted}) + return writeTable(a, cmd, jobs.Items, spec, servicePage(jobs, page)) }, } pageFlags(cmd, &limit, &page) diff --git a/internal/app/action_runs.go b/internal/app/action_runs.go index e60a2623..3542fa8d 100644 --- a/internal/app/action_runs.go +++ b/internal/app/action_runs.go @@ -86,7 +86,7 @@ $ tea run list --since 24h --json id,status,conclusion,workflow`, empty = "no runs match your filters in " + t.Repo } spec := listSpec[services.Run]{Columns: runColumns, Row: runRow(a.deps.Now()), Empty: empty, Context: "runs in " + t.Repo} - return writeTable(a, cmd, page.Items, spec, listPage{Number: filters.Page, More: page.More, Total: page.Total, Counted: page.Counted}) + return writeTable(a, cmd, page.Items, spec, servicePage(page, filters.Page)) }, } pageFlags(cmd, &filters.Limit, &filters.Page) @@ -202,7 +202,7 @@ $ tea run view 34 --json jobs --jq '.jobs[] | {name, conclusion}'`, return err } if web { - return a.openBrowser(cmd, fmt.Sprintf("%s/%s/actions/runs/%d", strings.TrimSuffix(t.Host, "/"), t.Repo, id)) + return a.openBrowser(cmd, fmt.Sprintf("%s/actions/runs/%d", repositoryURL(t), id)) } service := services.Actions{Client: t.client, Repository: t.Repo} if log { @@ -413,7 +413,7 @@ func (a *application) runArg(cmd *cobra.Command, args []string, active bool) (ta return a.resolveRef(cmd, args[0], runRef) } missing := clierr.Wrap(clierr.Usage, &argumentError{problem: "missing run ID", cmd: cmd}) - chooser, ok := a.runPicker() + chooser, ok := a.chooser() if !ok { return target{}, 0, missing } @@ -449,16 +449,6 @@ func (a *application) runArg(cmd *cobra.Command, args []string, active bool) (ta return t, id, err } -// runPicker returns the prompter's selection prompt when prompts are allowed. -func (a *application) runPicker() (terminal.Chooser, bool) { - chooser, ok := a.deps.Prompter.(terminal.Chooser) - if !ok { - return nil, false - } - s, err := a.deps.Settings.Load() - return chooser, err == nil && a.policy(s).CanPrompt(a.deps.Streams) -} - // accountNote reminds people who pass the global --user, which selects the account tea acts as, // of the flag they may have meant, as gh's --user means something else. func (a *application) accountNote(cmd *cobra.Command, t target, hint string) { diff --git a/internal/app/action_values.go b/internal/app/action_values.go index b8763f7d..40e13380 100644 --- a/internal/app/action_values.go +++ b/internal/app/action_values.go @@ -283,11 +283,7 @@ func (a *application) actionValue(cmd *cobra.Command, secret bool, name, body, b } return strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r"), nil } - s, err := a.deps.Settings.Load() - if err != nil { - return "", err - } - if !a.policy(s).CanPrompt(a.deps.Streams) { + if !a.canPrompt() { return "", missing } if secret { diff --git a/internal/app/actions.go b/internal/app/actions.go index 7100c271..30da3526 100644 --- a/internal/app/actions.go +++ b/internal/app/actions.go @@ -341,11 +341,7 @@ func (a *application) dispatchValues(cmd *cobra.Command, workflow services.Workf } return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("unknown input %q for %s; inputs: %s", key, workflow.Label(), strings.Join(names, ", "))) } - s, err := a.deps.Settings.Load() - if err != nil { - return nil, err - } - canPrompt := a.policy(s).CanPrompt(a.deps.Streams) + canPrompt := a.canPrompt() for _, input := range declared { value, given := values[input.Name] missing := input.Required && input.Default == "" @@ -357,9 +353,11 @@ func (a *application) dispatchValues(cmd *cobra.Command, workflow services.Workf } values[input.Name] = normalized case canPrompt && (askAll || missing): - if values[input.Name], err = a.askInput(cmd, input); err != nil { + answer, err := a.askInput(cmd, input) + if err != nil { return nil, err } + values[input.Name] = answer case missing: return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("missing required input %s for %s; pass -f %s=", input.Name, workflow.Label(), input.Name)) } diff --git a/internal/app/admin_users.go b/internal/app/admin_users.go index 80df1408..9056a07e 100644 --- a/internal/app/admin_users.go +++ b/internal/app/admin_users.go @@ -292,11 +292,7 @@ func (a *application) adminUserPassword(cmd *cobra.Command, username, password, return "", err } if password == "" && !cmd.Flags().Changed("password-file") { - snapshot, err := a.deps.Settings.Load() - if err != nil { - return "", err - } - if !a.policy(snapshot).CanPrompt(a.deps.Streams) { + if !a.canPrompt() { return "", clierr.Wrap(clierr.Usage, errors.New("missing password: supply --password-file or --password")) } if password, err = a.deps.Prompter.SecretInput(cmd.Context(), "Password for "+username); err != nil { diff --git a/internal/app/app.go b/internal/app/app.go index eaec9e16..d811f99a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -14,6 +14,7 @@ import ( "regexp" "slices" "strings" + "sync" "time" "unicode" @@ -71,6 +72,27 @@ type options struct { type application struct { deps Dependencies options options + + // snapshot caches the settings for one run; generation is the store's generation it was loaded at. + mu sync.Mutex + snapshot *settings.Snapshot + generation uint64 +} + +// settings returns the settings, loaded once per run and again after tea changed them. +func (a *application) settings() (settings.Snapshot, error) { + a.mu.Lock() + defer a.mu.Unlock() + generation := a.deps.Settings.Generation() + if a.snapshot != nil && a.generation == generation { + return *a.snapshot, nil + } + snapshot, err := a.deps.Settings.Load() + if err != nil { + return snapshot, err + } + a.snapshot, a.generation = &snapshot, generation + return snapshot, nil } // NewRoot builds fresh commands. Construction performs no disk, network, or Git IO. @@ -144,7 +166,7 @@ func NewRoot(deps Dependencies) *cobra.Command { if offline(cmd) { return nil } - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return err } diff --git a/internal/app/artifacts.go b/internal/app/artifacts.go index 8b89dbbb..fff6d7fa 100644 --- a/internal/app/artifacts.go +++ b/internal/app/artifacts.go @@ -46,7 +46,7 @@ $ tea run artifact list 34`, return err } spec := listSpec[services.Artifact]{Columns: artifactColumns, Row: artifactRow, Empty: fmt.Sprintf("no artifacts in run %d", run), Context: fmt.Sprintf("artifacts of run %d", run)} - return writeTable(a, cmd, items.Items, spec, listPage{Number: page, More: items.More, Total: items.Total, Counted: items.Counted}) + return writeTable(a, cmd, items.Items, spec, servicePage(items, page)) }, } pageFlags(list, &limit, &page) diff --git a/internal/app/auth.go b/internal/app/auth.go index e1277729..415103de 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -161,7 +161,7 @@ $ tea auth login --host gitea.com --ssh-key ~/.ssh/id_ed25519`, case selected == 1 && !oauth && cmd.Flags().Changed("scopes"): return clierr.Wrap(clierr.Usage, errors.New("--scopes requires --oauth")) } - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return err } @@ -321,7 +321,7 @@ $ tea auth login --host gitea.com --ssh-key ~/.ssh/id_ed25519`, // confirmReplace asks before a login replaces the stored credential of an account that tea already // has, and reports whether it does. func (a *application) confirmReplace(cmd *cobra.Command, instance, user string) (bool, error) { - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return false, err } @@ -437,7 +437,7 @@ credential is missing or rejected.`, $ tea auth status --verify $ tea auth status --host gitea.com --json`, RunE: func(cmd *cobra.Command, _ []string) error { - s, err := a.deps.Settings.Load() + s, err := a.settings() if err != nil { return err } @@ -708,12 +708,8 @@ asks which one on a terminal. The instance is the one of the current repository, // chooseAccount asks which of the accounts of instance to use, or explains how to name one. func (a *application) chooseAccount(cmd *cobra.Command, instance string, names []string, active string) (string, error) { - s, err := a.deps.Settings.Load() - if err != nil { - return "", err - } - chooser, ok := a.deps.Prompter.(terminal.Chooser) - if !ok || !a.policy(s).CanPrompt(a.deps.Streams) { + chooser, ok := a.chooser() + if !ok { return "", clierr.Wrap(clierr.Usage, fmt.Errorf("name the account: tea auth switch (accounts on %s: %s)", instance, strings.Join(names, ", "))) } choices := make([]terminal.Choice, len(names)) @@ -774,7 +770,7 @@ instance.`, a.warn(cmd, "Could not ask git's credential helpers to forget %s on %s", t.User, t.Host) } } - if s, err := a.deps.Settings.Load(); err == nil { + if s, err := a.settings(); err == nil { if active := s.Hosts.Hosts[t.Host].User; active != "" && active != t.host.User { a.done(cmd, "Switched active account for %s to %s", t.Host, active) } @@ -833,7 +829,7 @@ $ tea auth token --host gitea.com --user alice`, if err != nil { return err } - s, err := a.deps.Settings.Load() + s, err := a.settings() if err != nil { return err } diff --git a/internal/app/branches.go b/internal/app/branches.go index 252785be..076d1a2e 100644 --- a/internal/app/branches.go +++ b/internal/app/branches.go @@ -104,7 +104,7 @@ $ tea branch list -R alice/project --limit 100 --json name,sha`, }, Context: "branches in " + target.Repo, Empty: "no branches in " + target.Repo, } - return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted}) + return writeTable(a, cmd, result.Items, spec, servicePage(result, page)) }, } pageFlags(command, &limit, &page) @@ -145,7 +145,7 @@ func (a *application) branchViewCommand() *cobra.Command { protection := "" if item.Protected { badges = append(badges, terminal.Cell{Text: "protected", Color: "yellow"}) - protection = countNoun(int(item.RequiredApprovals), "required approval") + protection = terminal.Plural(int(item.RequiredApprovals), "required approval") } meta := "" if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() { @@ -358,7 +358,7 @@ $ tea branch protect --pattern 'release/*' --allow-push acme/release`, // protectionSummary describes a protection rule, as in "1 approval required, no direct pushes". func protectionSummary(rule services.Protection) string { - parts := []string{countNoun(int(rule.RequiredApprovals), "approval") + " required"} + parts := []string{terminal.Plural(int(rule.RequiredApprovals), "approval") + " required"} if pushers := len(rule.PushUsers) + len(rule.PushTeams); pushers > 0 { parts = append(parts, "pushes by "+strings.Join(append(append([]string{}, rule.PushUsers...), rule.PushTeams...), ", ")) } else { diff --git a/internal/app/browser.go b/internal/app/browser.go index 0b2a34bb..6a286c0f 100644 --- a/internal/app/browser.go +++ b/internal/app/browser.go @@ -5,6 +5,7 @@ package app import ( "fmt" + "net/url" "strings" "gitea.dev/tea/internal/terminal" @@ -27,3 +28,17 @@ func (a *application) openBrowser(command *cobra.Command, resource string) error func displayURL(url string) string { return terminal.SafeLine(strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")) } + +// webURL is the page of a path on the target's instance, such as an organization's profile. +func webURL(t target, path ...string) string { + escaped := make([]string, len(path)) + for i, segment := range path { + escaped[i] = url.PathEscape(segment) + } + return strings.TrimSuffix(t.Host, "/") + "/" + strings.Join(escaped, "/") +} + +// repositoryURL returns the web URL of the target's repository. +func repositoryURL(t target) string { + return strings.TrimSuffix(t.Host, "/") + "/" + t.Repo +} diff --git a/internal/app/checkout.go b/internal/app/checkout.go index 1a2795f6..0cd4ce02 100644 --- a/internal/app/checkout.go +++ b/internal/app/checkout.go @@ -91,7 +91,7 @@ argument select it, and the branch tracks the head branch, so that git push upda if err != nil { return err } - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return err } diff --git a/internal/app/checks.go b/internal/app/checks.go index f037f63d..228abc2a 100644 --- a/internal/app/checks.go +++ b/internal/app/checks.go @@ -52,7 +52,8 @@ tea exits with 8 while checks are pending and with 1 when a check failed, like g if err != nil { waiting := "checks to be reported" if len(latest) > 0 { - waiting = plural(pendingChecks(latest), "pending check") + _, pending, _ := services.Tally(latest) + waiting = terminal.Plural(pending, "pending check") } return timeoutError(err, timeout, "waiting for %s", waiting) } @@ -94,45 +95,18 @@ $ tea pr checks 13 --watch && tea pr merge 13` // checksOutcome is the exit status of checks: 1 when one failed, 8 while any is pending. func checksOutcome(items []services.Check) error { - pending := false - for _, item := range items { - switch { - case item.Pending(): - pending = true - case !item.Passed(): - return clierr.Quiet(clierr.Failure) - } - } - if pending { + switch failing, pending, _ := services.Tally(items); { + case failing > 0: + return clierr.Quiet(clierr.Failure) + case pending > 0: return clierr.Quiet(clierr.Pending) } return nil } -// pendingChecks counts the checks that have not finished. -func pendingChecks(items []services.Check) int { - pending := 0 - for _, item := range items { - if item.Pending() { - pending++ - } - } - return pending -} - // checkCounts words how the checks stand: "0 failing, 1 successful, and 2 pending checks". func checkCounts(items []services.Check) string { - var failing, passed, pending int - for _, item := range items { - switch { - case item.Pending(): - pending++ - case item.Passed(): - passed++ - default: - failing++ - } - } + failing, pending, passed := services.Tally(items) return fmt.Sprintf("%d failing, %d successful, and %d pending checks", failing, passed, pending) } diff --git a/internal/app/comments.go b/internal/app/comments.go index a924df49..4fd5e924 100644 --- a/internal/app/comments.go +++ b/internal/app/comments.go @@ -21,14 +21,14 @@ const commentTitle = "Comment (markdown):" // commentNouns are the words for the commands of an issue or pull request comment group. type commentNouns struct { - group, noun, arguments, example string + group, arguments, example string } func commentWords(kind refKind) commentNouns { if kind == pullRef { - return commentNouns{group: "pr", noun: "pull request", arguments: pullNumberArguments, example: "13"} + return commentNouns{group: "pr", arguments: pullNumberArguments, example: "13"} } - return commentNouns{group: "issue", noun: "issue", arguments: issueArguments, example: "12"} + return commentNouns{group: "issue", arguments: issueArguments, example: "12"} } // pullNumberArguments is the ARGUMENTS help of pr comment and pr attachment commands, which take pull @@ -258,10 +258,10 @@ func (a *application) commentCommand(kind refKind) *cobra.Command { words := commentWords(kind) var flags commentFlags var editLast, deleteLast bool - group := &cobra.Command{Use: "comment { | }", Short: "Add a comment to " + article(words.noun), Long: fmt.Sprintf(`Add a comment to %[1]s, or edit or delete your last comment on it. + group := &cobra.Command{Use: "comment { | }", Short: "Add a comment to " + kind.article(), Long: fmt.Sprintf(`Add a comment to %[1]s, or edit or delete your last comment on it. Without --body, --body-file or --editor, the configured editor opens, or a multi-line prompt -asks for the comment. The subcommands list, view, edit and delete comments by their ID.`, article(words.noun)), Example: fmt.Sprintf(`$ tea %[1]s comment %[2]s --body "Fixed in #14" +asks for the comment. The subcommands list, view, edit and delete comments by their ID.`, kind.article()), Example: fmt.Sprintf(`$ tea %[1]s comment %[2]s --body "Fixed in #14" $ tea %[1]s comment %[2]s --body-file notes.md # Change or remove your last comment $ tea %[1]s comment %[2]s --edit-last --body "Fixed in #15" @@ -269,7 +269,7 @@ $ tea %[1]s comment %[2]s --delete-last --yes $ tea %[1]s comment list %[2]s`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments, "tea.confirm": "true"}, Args: commentArgs(kind), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { if localFlagsChanged(cmd) { - return clierr.Wrap(clierr.Usage, &argumentError{problem: "missing " + words.noun + " number", cmd: cmd}) + return clierr.Wrap(clierr.Usage, &argumentError{problem: "missing " + kind.noun() + " number", cmd: cmd}) } return cmd.Help() } @@ -308,7 +308,7 @@ $ tea %[1]s comment list %[2]s`, words.group, words.example), Annotations: map[s group.ValidArgsFunction = a.completeDiscussions(kind) var limit, page int - list := &cobra.Command{Use: "list { | }", Short: "List the comments on " + article(words.noun), Example: fmt.Sprintf(`$ tea %[1]s comment list %[2]s + list := &cobra.Command{Use: "list { | }", Short: "List the comments on " + kind.article(), Example: fmt.Sprintf(`$ tea %[1]s comment list %[2]s $ tea %[1]s comment list %[2]s --json id,author,body`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { t, n, err := a.resolveRef(cmd, args[0], kind) if err != nil { @@ -345,7 +345,7 @@ $ tea %[1]s comment list %[2]s --json id,author,body`, words.group, words.exampl }} var createFlags commentFlags - create := &cobra.Command{Use: "create { | }", Aliases: []string{"add"}, Short: "Add a comment to " + article(words.noun), Example: fmt.Sprintf(`$ tea %[1]s comment create %[2]s --body "Looks good" + create := &cobra.Command{Use: "create { | }", Aliases: []string{"add"}, Short: "Add a comment to " + kind.article(), Example: fmt.Sprintf(`$ tea %[1]s comment create %[2]s --body "Looks good" $ tea %[1]s comment add %[2]s --editor`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { if err := createFlags.validate(cmd); err != nil { return err @@ -415,14 +415,6 @@ func commentArgs(kind refKind) cobra.PositionalArgs { } } -// article returns noun with its indefinite article: "an issue", "a pull request". -func article(noun string) string { - if strings.ContainsRune("aeiou", rune(noun[0])) { - return "an " + noun - } - return "a " + noun -} - // completeDiscussions completes the open issues or pull requests that a command of kind takes. func (a *application) completeDiscussions(kind refKind) cobra.CompletionFunc { if kind == pullRef { diff --git a/internal/app/completion.go b/internal/app/completion.go index 786fd8c5..ecf53f2b 100644 --- a/internal/app/completion.go +++ b/internal/app/completion.go @@ -174,7 +174,7 @@ func completionContext(cmd *cobra.Command) (context.Context, context.CancelFunc) // completeHosts completes the configured instances with their accounts. func (a *application) completeHosts(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -189,7 +189,7 @@ func (a *application) completeHosts(_ *cobra.Command, _ []string, toComplete str func (a *application) completeAccounts(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { ctx, cancel := completionContext(cmd) defer cancel() - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -244,7 +244,7 @@ func (a *application) completeLocalBranches(cmd *cobra.Command, _ []string, toCo // completeAliases completes the configured alias names with their expansions. func (a *application) completeAliases(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -449,7 +449,7 @@ func (a *application) completeRepos() cobra.CompletionFunc { remotes := func(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { ctx, cancel := completionContext(cmd) defer cancel() - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/app/config.go b/internal/app/config.go index be4f130f..3794c471 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -216,7 +216,7 @@ $ tea config get default_host --show-source $ tea config get ssh_host --host https://gitea.com $ tea config get ssh_key --host https://gitea.com --user alice`, RunE: func(cmd *cobra.Command, args []string) error { - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return err } @@ -247,7 +247,7 @@ $ tea config get ssh_key --host https://gitea.com --user alice`, # Include the settings of an instance $ tea config list --host https://gitea.com`, RunE: func(cmd *cobra.Command, _ []string) error { - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return err } @@ -348,7 +348,7 @@ func (a *application) reportSet(cmd *cobra.Command, key, value, scope string) { // warnWithoutAccount points out a default_host that has no account to act as yet. func (a *application) warnWithoutAccount(cmd *cobra.Command) { - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if host := snapshot.Preferences.DefaultHost; err == nil && len(snapshot.Hosts.Hosts[host].Users) == 0 { a.warn(cmd, "No account on %s yet; log in with: tea auth login --host %s", host, host) } @@ -564,7 +564,7 @@ $ tea alias set il "issue list --assignee @me" --clobber`, Use: "list", Short: "List shortcuts", Args: argsExact(0), Example: "$ tea alias list\n# Print the expansion of one alias\n$ tea alias list --jq .il", RunE: func(cmd *cobra.Command, _ []string) error { - s, err := a.deps.Settings.Load() + s, err := a.settings() if err != nil { return err } diff --git a/internal/app/discussion_view.go b/internal/app/discussion_view.go index e6148aa5..78f674b0 100644 --- a/internal/app/discussion_view.go +++ b/internal/app/discussion_view.go @@ -200,17 +200,7 @@ func reviewerSummary(item services.Pull, reviews []services.Review) string { // checkSummary counts checks by outcome: "1 failing, 2 pending, 3 successful", or "" without checks. func checkSummary(checks []services.Check) string { - var failing, pending, passed int - for _, check := range checks { - switch { - case check.Pending(): - pending++ - case check.Passed(): - passed++ - default: - failing++ - } - } + failing, pending, passed := services.Tally(checks) var parts []string for _, count := range []struct { n int @@ -315,13 +305,13 @@ func (a *application) writeDiscussion(cmd *cobra.Command, target target, v discu _, err := fmt.Fprint(cmd.OutOrStdout(), b.String()) return err } - view.Meta = v.summary(now) + " • " + plural(int(v.comments), "comment") + view.Meta = v.summary(now) + " • " + terminal.Plural(int(v.comments), "comment") view.URL = "" muted := func(text string) string { return terminal.Colorize("muted", text, d.Color) } var b strings.Builder b.WriteString(view.Render(d)) if !withComments && v.comments > 0 { - b.WriteString("\n" + muted(plural(int(v.comments), "comment")+" — use --comments to view them") + "\n") + b.WriteString("\n" + muted(terminal.Plural(int(v.comments), "comment")+" — use --comments to view them") + "\n") } for _, comment := range comments { heading := []string{terminal.Emphasis(terminal.SafeLine(comment.author), d.Color)} @@ -346,10 +336,3 @@ func (a *application) writeDiscussion(cmd *cobra.Command, target target, v discu b.WriteString("\n" + muted("View this "+v.view.Kind+" on Gitea: "+terminal.SafeLine(v.view.URL)) + "\n") return a.writeHuman(cmd, b.String()) } - -func plural(n int, unit string) string { - if n == 1 { - return "1 " + unit - } - return fmt.Sprintf("%d %ss", n, unit) -} diff --git a/internal/app/doctor.go b/internal/app/doctor.go index 2183a60a..cc6beb8e 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -83,7 +83,7 @@ problem comes with the command that fixes it. tea doctor fails when a check fail func (a *application) diagnose(ctx context.Context) doctorReport { var report doctorReport add := func(check doctorCheck) { report.Checks = append(report.Checks, check) } - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { add(doctorCheck{"Config", checkFailed, err.Error(), "repair or move the file; tea config path prints both files"}) add(a.gitCheck(ctx, "")) diff --git a/internal/app/git.go b/internal/app/git.go index 75b639ad..8392f6e9 100644 --- a/internal/app/git.go +++ b/internal/app/git.go @@ -16,7 +16,7 @@ func (a *application) git(ctx context.Context, args ...string) (string, error) { // gitIn runs Git in dir, which is relative to the process's working directory, not to --cwd. func (a *application) gitIn(ctx context.Context, dir string, args ...string) (string, error) { - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return "", err } diff --git a/internal/app/git_credentials.go b/internal/app/git_credentials.go index d39958b8..53ef5697 100644 --- a/internal/app/git_credentials.go +++ b/internal/app/git_credentials.go @@ -32,7 +32,7 @@ func (a *application) gitCredentialCommand() *cobra.Command { if args[0] != "get" { return nil } - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return err } diff --git a/internal/app/human_output.go b/internal/app/human_output.go index fbe406ee..75474ca9 100644 --- a/internal/app/human_output.go +++ b/internal/app/human_output.go @@ -15,7 +15,7 @@ import ( // preferences applies environment overrides to the stored preferences, matching config get --show-source. func (a *application) preferences() (settings.Preferences, error) { - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return settings.Preferences{}, err } diff --git a/internal/app/issue_attachments.go b/internal/app/issue_attachments.go index 269e6aa1..3c274c06 100644 --- a/internal/app/issue_attachments.go +++ b/internal/app/issue_attachments.go @@ -45,8 +45,8 @@ func attachmentView(item services.IssueAttachment) terminal.View { func (a *application) issueAttachmentCommand(kind refKind) *cobra.Command { words := commentWords(kind) - group := &cobra.Command{Use: "attachment", Short: "Manage the attachments of " + words.noun + "s", Long: fmt.Sprintf(`List, view, upload, download, rename and delete the files attached to %s. Attachments -are addressed by the %s number and the attachment ID that attachment list shows.`, article(words.noun), words.noun)} + group := &cobra.Command{Use: "attachment", Short: "Manage the attachments of " + kind.noun() + "s", Long: fmt.Sprintf(`List, view, upload, download, rename and delete the files attached to %s. Attachments +are addressed by the %s number and the attachment ID that attachment list shows.`, kind.article(), kind.noun())} annotations := map[string]string{"tea.arguments": words.arguments} // discussionTarget resolves the issue or pull request argument of an attachment command. discussionTarget := func(cmd *cobra.Command, raw string) (target, int64, error) { diff --git a/internal/app/issues.go b/internal/app/issues.go index 4e1295ac..6a9544f2 100644 --- a/internal/app/issues.go +++ b/internal/app/issues.go @@ -111,7 +111,7 @@ func issuesURL(t target, opts services.ListOptions) string { if opts.Search != "" { query.Set("q", opts.Search) } - return strings.TrimRight(t.Host, "/") + "/" + t.Repo + "/" + section + "?" + query.Encode() + return repositoryURL(t) + "/" + section + "?" + query.Encode() } // issueListSpec describes issue tables of the issues where, such as "in alice/x": STATE shows on a @@ -258,7 +258,7 @@ $ tea issue create --title "Idea" --web`, Args: argsExact(0), RunE: func(cmd *co return err } query := url.Values{"title": {title}, "body": {cmp.Or(body, templateBody)}} - return a.openBrowser(cmd, strings.TrimRight(target.Host, "/")+"/"+target.Repo+"/issues/new?"+query.Encode()) + return a.openBrowser(cmd, repositoryURL(target)+"/issues/new?"+query.Encode()) } if bodyFile == "-" && title == "" { return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires --title; stdin cannot also supply a prompt")) diff --git a/internal/app/metadata.go b/internal/app/metadata.go index 9b7334fe..a98605af 100644 --- a/internal/app/metadata.go +++ b/internal/app/metadata.go @@ -113,7 +113,7 @@ func milestoneName(t target, item services.Milestone) string { // milestoneURL is the web page of a milestone, which the API does not return. func milestoneURL(t target, item services.Milestone) string { - return strings.TrimRight(t.Host, "/") + "/" + t.Repo + "/milestone/" + strconv.FormatInt(item.ID, 10) + return repositoryURL(t) + "/milestone/" + strconv.FormatInt(item.ID, 10) } func milestoneView(t target, now time.Time) func(services.Milestone) terminal.View { diff --git a/internal/app/notifications.go b/internal/app/notifications.go index a19d997c..7ae7fae1 100644 --- a/internal/app/notifications.go +++ b/internal/app/notifications.go @@ -99,7 +99,7 @@ list shows identify notification threads; they are not issue numbers.`} }, func(item services.Notification) string { return fmt.Sprintf(mark.done, notificationLabel(item)) }) } what := notificationsWith(filters) + " " + s.where - if settings, err := a.deps.Settings.Load(); err == nil && a.policy(settings).CanPrompt(a.deps.Streams) { + if a.canPrompt() { if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf(mark.ask, what), DefaultYes: true}); err != nil { return err } @@ -115,7 +115,7 @@ list shows identify notification threads; they are not issue numbers.`} a.warn(cmd, "No %s", what) return nil } - a.done(cmd, mark.done, plural(len(results), "notification")) + a.done(cmd, mark.done, terminal.Plural(len(results), "notification")) return nil }, } @@ -176,7 +176,7 @@ $ tea notification list --type pull --since 24h`, what := notificationsWith(opts.Statuses) + " " + s.where return writeTable(a, cmd, page.Items, listSpec[services.Notification]{ Columns: notificationColumns, Row: notificationRow, Empty: "no " + what, Context: what, - }, listPage{Number: opts.Page, More: page.More, Total: page.Total, Counted: page.Counted}) + }, servicePage(page, opts.Page)) }, } notificationFilters(command, &opts, &updated) diff --git a/internal/app/organizations.go b/internal/app/organizations.go index 9a05b17a..397b5713 100644 --- a/internal/app/organizations.go +++ b/internal/app/organizations.go @@ -7,7 +7,6 @@ import ( "context" "errors" "fmt" - "net/url" "strconv" "strings" "time" @@ -47,15 +46,6 @@ func countOf(n int, singular, plural string) string { return strconv.Itoa(n) + " " + plural } -// webURL is the page of a path on the target's instance, such as an organization's profile. -func webURL(t target, path ...string) string { - escaped := make([]string, len(path)) - for i, segment := range path { - escaped[i] = url.PathEscape(segment) - } - return strings.TrimSuffix(t.Host, "/") + "/" + strings.Join(escaped, "/") -} - func (a *application) organizationCommand() *cobra.Command { group := &cobra.Command{Use: "org", Short: "Manage organizations", Long: `Work with Gitea organizations: list the ones you belong to, view, create, edit and delete them, and manage their members. diff --git a/internal/app/prompts.go b/internal/app/prompts.go index c8a9a034..72681fef 100644 --- a/internal/app/prompts.go +++ b/internal/app/prompts.go @@ -12,13 +12,25 @@ import ( // DefaultYes, destructive ones keep the default no, and irreversible ones set Typed. A declined // confirmation returns terminal.ErrCanceled. func (a *application) confirmAction(cmd *cobra.Command, c terminal.Confirmation) error { - s, err := a.deps.Settings.Load() + s, err := a.settings() if err != nil { return err } return terminal.ConfirmAction(cmd.Context(), c, a.options.yes, a.deps.Streams, a.policy(s), a.deps.Prompter) } +// canPrompt reports whether tea may ask questions on this terminal. +func (a *application) canPrompt() bool { + s, err := a.settings() + return err == nil && a.policy(s).CanPrompt(a.deps.Streams) +} + +// chooser returns the prompter's selection prompt when prompts are allowed. +func (a *application) chooser() (terminal.Chooser, bool) { + chooser, ok := a.deps.Prompter.(terminal.Chooser) + return chooser, ok && a.canPrompt() +} + // deleteConfirmed asks to delete subject, such as `label "bug" from alice/x`, deletes it with del // and reports "Deleted ". func (a *application) deleteConfirmed(cmd *cobra.Command, subject string, del func() error) error { @@ -36,7 +48,7 @@ func (a *application) deleteConfirmed(cmd *cobra.Command, subject string, del fu // until the answer is non-empty and validate, when set, accepts it. Without prompts it returns a // usage error naming --flag. It reports whether it prompted. func (a *application) requireInput(cmd *cobra.Command, value *string, flag, label string, validate func(string) error) (bool, error) { - s, err := a.deps.Settings.Load() + s, err := a.settings() if err != nil { return false, err } diff --git a/internal/app/pull_clean.go b/internal/app/pull_clean.go index 9ba42b8c..d1ce3e80 100644 --- a/internal/app/pull_clean.go +++ b/internal/app/pull_clean.go @@ -24,7 +24,7 @@ func (a *application) pullCleanCommand() *cobra.Command { if err != nil { return err } - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return err } diff --git a/internal/app/pull_create.go b/internal/app/pull_create.go index 5ff16f4b..40008ba8 100644 --- a/internal/app/pull_create.go +++ b/internal/app/pull_create.go @@ -74,7 +74,7 @@ is not on the remote yet is pushed first, with its upstream set.`, Args: argsExa return err } } - s, err := a.deps.Settings.Load() + s, err := a.settings() if err != nil { return err } @@ -260,7 +260,7 @@ func (a *application) defaultPullHead(ctx context.Context) (owner, branch string if err != nil || raw == "" { return "", branch } - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return "", branch } diff --git a/internal/app/pull_review.go b/internal/app/pull_review.go index 2aa7ec7a..c1379528 100644 --- a/internal/app/pull_review.go +++ b/internal/app/pull_review.go @@ -250,12 +250,6 @@ func (a *application) reviewCreateCommand(use, short string) *cobra.Command { return cmd } -// canPrompt reports whether tea may ask questions on this terminal. -func (a *application) canPrompt() bool { - s, err := a.deps.Settings.Load() - return err == nil && a.policy(s).CanPrompt(a.deps.Streams) -} - // ownReview refuses to approve or request changes on one's own pull request before any body is // written, as Gitea would after. func ownReview(pull services.Pull, t target, state string) error { diff --git a/internal/app/pulls.go b/internal/app/pulls.go index d6b331b2..8ae2f527 100644 --- a/internal/app/pulls.go +++ b/internal/app/pulls.go @@ -109,7 +109,7 @@ each pull request, which tea only makes when --json names them.`, Args: argsExac } }, Empty: empty, Context: what, - }, listPage{Number: page, More: window.More, Total: window.Total, Counted: window.Counted}) + }, servicePage(window, page)) }} enumFlag(cmd, &state, "state", "s", "open", "Filter by state", "open", "closed", "merged", "all") pageFlags(cmd, &limit, &page) @@ -163,11 +163,6 @@ func (a *application) jsonSelects(fields ...string) bool { return false } -// repositoryURL returns the web URL of the target's repository. -func repositoryURL(t target) string { - return strings.TrimSuffix(t.Host, "/") + "/" + t.Repo -} - // milestoneFilter resolves a milestone given as an ID or a title (D10): a number is an ID first, // then a title. func milestoneFilter(ctx context.Context, t target, raw string) (int64, error) { diff --git a/internal/app/ref.go b/internal/app/ref.go index d713d16f..0cf7ee8c 100644 --- a/internal/app/ref.go +++ b/internal/app/ref.go @@ -177,7 +177,7 @@ func (a *application) resolveRefs(cmd *cobra.Command, raws []string, kind refKin // sameRepository reports whether two repository arguments name the same repository. func (a *application) sameRepository(first, second string) bool { - s, err := a.deps.Settings.Load() + s, err := a.settings() if err != nil { return strings.EqualFold(first, second) } diff --git a/internal/app/releases.go b/internal/app/releases.go index 44160d2b..210a9477 100644 --- a/internal/app/releases.go +++ b/internal/app/releases.go @@ -82,7 +82,7 @@ $ tea release list -R alice/project --json tag,title,draft`, }, Context: "releases in " + target.Repo, Empty: "no releases in " + target.Repo, } - return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted}) + return writeTable(a, cmd, result.Items, spec, servicePage(result, page)) }, } pageFlags(command, &limit, &page) diff --git a/internal/app/repos.go b/internal/app/repos.go index 6924a57a..a0154493 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -173,7 +173,7 @@ $ tea repo list --starred --json full_name,url`, } collection := repositoryCollection(opts) spec := listSpec[services.Repository]{Columns: repositoryColumns, Row: repositoryRow, Context: collection, Empty: "no " + collection} - return writeTable(a, cmd, result.Items, spec, listPage{Number: opts.Page, More: result.More, Total: result.Total, Counted: result.Counted}) + return writeTable(a, cmd, result.Items, spec, servicePage(result, opts.Page)) }, } pageFlags(list, &opts.Limit, &opts.Page) @@ -294,8 +294,8 @@ func (a *application) repositoryView(item services.Repository, readme string) te badges = append(badges, badge) } meta := []string{ - fmt.Sprintf("★ %d", item.Stars), countNoun(item.Forks, "fork"), countNoun(item.OpenIssues, "open issue"), - countNoun(item.OpenPulls, "open pull request"), + fmt.Sprintf("★ %d", item.Stars), terminal.Plural(item.Forks, "fork"), terminal.Plural(item.OpenIssues, "open issue"), + terminal.Plural(item.OpenPulls, "open pull request"), } if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() { meta = append(meta, "updated "+terminal.RelativeTime(a.deps.Now(), updated)) @@ -328,11 +328,3 @@ func parentName(item services.Repository) string { } return item.Parent.FullName } - -// countNoun counts n of a noun, as in "1 fork" or "3 forks". -func countNoun(n int, noun string) string { - if n == 1 { - return "1 " + noun - } - return fmt.Sprintf("%d %ss", n, noun) -} diff --git a/internal/app/repository_create.go b/internal/app/repository_create.go index 5ec68c69..b7fe1ff2 100644 --- a/internal/app/repository_create.go +++ b/internal/app/repository_create.go @@ -90,7 +90,7 @@ $ tea repo create project --source . --push`, if err := validateRepositoryName(opts.Name); err != nil { return err } - selected := *a + selected := &application{deps: a.deps, options: a.options} selected.options.repo = template target, err := selected.resolve(cmd.Context(), false) if err != nil { diff --git a/internal/app/repository_sync.go b/internal/app/repository_sync.go index 659bf18e..0a7e52b5 100644 --- a/internal/app/repository_sync.go +++ b/internal/app/repository_sync.go @@ -50,7 +50,7 @@ $ tea repo sync bob/project`, if err != nil { return err } - snapshot, err := a.deps.Settings.Load() + snapshot, err := a.settings() if err != nil { return err } diff --git a/internal/app/search.go b/internal/app/search.go index 09059c2d..1fb01912 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -55,7 +55,7 @@ $ tea search repo go --topic --sort updated --order desc`, what += " matching " + opts.Query } spec := listSpec[services.Repository]{Columns: repositoryColumns, Row: repositoryRow, Context: what, Empty: "no " + what} - return writeTable(a, cmd, result.Items, spec, listPage{Number: opts.Page, More: result.More, Total: result.Total, Counted: result.Counted}) + return writeTable(a, cmd, result.Items, spec, servicePage(result, opts.Page)) }, } flags := command.Flags() diff --git a/internal/app/target.go b/internal/app/target.go index b04337d8..95613409 100644 --- a/internal/app/target.go +++ b/internal/app/target.go @@ -110,7 +110,7 @@ func (a *application) resolveRequest(ctx context.Context, req targetRequest) (ta // connect selects the instance, account and repository and builds the authenticated client. func (a *application) connect(ctx context.Context, req targetRequest) (target, error) { - s, err := a.deps.Settings.Load() + s, err := a.settings() if err != nil { return target{}, err } diff --git a/internal/app/webhooks.go b/internal/app/webhooks.go index 6507c050..440ce3b1 100644 --- a/internal/app/webhooks.go +++ b/internal/app/webhooks.go @@ -365,11 +365,7 @@ $ tea webhook create https://hooks.slack.com/services/T0/B0/X --org acme --type } askAuthorization := !create && authSources == 0 if askAuthorization { - snapshot, err := a.deps.Settings.Load() - if err != nil { - return err - } - if !a.policy(snapshot).CanPrompt(a.deps.Streams) { + if !a.canPrompt() { return clierr.Wrap(clierr.Usage, errors.New("editing a webhook drops its stored authorization header unless it is sent again, and Gitea never returns it: pass --authorization-header-file or --authorization-header (use --clear-authorization only for a hook without one)")) } } diff --git a/internal/app/wiki.go b/internal/app/wiki.go index e45719a4..9d9a41c6 100644 --- a/internal/app/wiki.go +++ b/internal/app/wiki.go @@ -57,7 +57,7 @@ func (a *application) wikiListCommand() *cobra.Command { }, Context: "wiki pages in " + target.Repo, Empty: "no wiki pages in " + target.Repo, } - return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted}) + return writeTable(a, cmd, result.Items, spec, servicePage(result, page)) }, } pageFlags(command, &limit, &page) @@ -102,7 +102,7 @@ func (a *application) wikiView(item services.WikiPage) terminal.View { meta = append(meta, "updated "+terminal.RelativeTime(a.deps.Now(), updated)) } if item.CommitCount > 0 { - meta = append(meta, countNoun(int(item.CommitCount), "revision")) + meta = append(meta, terminal.Plural(int(item.CommitCount), "revision")) } return terminal.View{ Title: item.Title, Meta: strings.Join(meta, " · "), Body: item.Body, Kind: "wiki page", URL: item.URL, @@ -226,11 +226,7 @@ func (a *application) wikiBody(cmd *cobra.Command, editor bool, initial string) if editor { return a.editDraft(cmd, initial) } - s, err := a.deps.Settings.Load() - if err != nil { - return terminal.Draft{}, err - } - if !a.policy(s).CanPrompt(a.deps.Streams) { + if !a.canPrompt() { return terminal.Draft{}, clierr.Wrap(clierr.Usage, errors.New("missing required input: supply --body, --body-file or --editor")) } return a.bodyEditor(cmd)(cmd.Context(), initial) diff --git a/internal/services/checks.go b/internal/services/checks.go index 12b7967e..1b79645f 100644 --- a/internal/services/checks.go +++ b/internal/services/checks.go @@ -32,6 +32,21 @@ func (c Check) Pending() bool { return c.State == "pending" } // Passed reports whether the check finished without blocking a merge; Gitea counts skipped as success. func (c Check) Passed() bool { return c.State == "success" || c.State == "skipped" } +// Tally counts checks by outcome. +func Tally(items []Check) (failing, pending, passed int) { + for _, item := range items { + switch { + case item.Pending(): + pending++ + case item.Passed(): + passed++ + default: + failing++ + } + } + return failing, pending, passed +} + // Checks returns latest contexts for the PR head in its base repository, as Gitea does. func (s Pulls) Checks(ctx context.Context, n int64) ([]Check, error) { sha, err := s.checkHead(ctx, n) @@ -126,16 +141,8 @@ func (s Pulls) WatchChecks(ctx context.Context, n int64, interval time.Duration, } previous = items } - pending := len(items) == 0 - failed := false - for _, item := range items { - if item.Pending() { - pending = true - } else if !item.Passed() { - failed = true - } - } - if failed || !pending { + failing, pending, _ := Tally(items) + if failing > 0 || (pending == 0 && len(items) > 0) { current, err := s.checkHead(ctx, n) if err != nil { return nil, err diff --git a/internal/settings/store.go b/internal/settings/store.go index 9f376236..532b675f 100644 --- a/internal/settings/store.go +++ b/internal/settings/store.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "time" "gitea.dev/tea/internal/filelock" @@ -102,8 +103,14 @@ type Store struct { Dir string LegacyPath string mu sync.Mutex + // generation counts the updates made through the store. + generation atomic.Uint64 } +// Generation changes whenever an update through the store ran, so callers can tell when a +// snapshot they loaded earlier may be stale. +func (s *Store) Generation() uint64 { return s.generation.Load() } + // New selects an explicit directory or the platform configuration directory. func New(dir string) *Store { legacy := "" @@ -328,6 +335,7 @@ func (s *Store) Update(target Target, update func(*Snapshot) error) error { // Transaction runs a compensating action under the lock if publishing the target file fails. // It is used to keep credential storage and account metadata consistent. func (s *Store) Transaction(target Target, update func(*Snapshot) (func() error, error)) error { + defer s.generation.Add(1) return s.Locked(func(snapshot Snapshot) error { unpublished := func() ([]byte, error) { if target == TargetHosts { diff --git a/internal/terminal/time.go b/internal/terminal/time.go index 5df2485a..693422de 100644 --- a/internal/terminal/time.go +++ b/internal/terminal/time.go @@ -32,7 +32,7 @@ func RelativeTime(now, t time.Time) string { default: n, unit = d/(365*day), "year" } - return prefix + "about " + plural(int(n), unit) + suffix + return prefix + "about " + Plural(int(n), unit) + suffix } // Timestamp formats t for scripts: RFC3339 in UTC at second precision. @@ -49,7 +49,8 @@ func ParseTime(raw string) time.Time { return t } -func plural(n int, unit string) string { +// Plural counts n of a unit, as in "1 fork" or "3 forks". +func Plural(n int, unit string) string { if n == 1 { return "1 " + unit }