gitstow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitstow/skill/SKILL.md ADDED
@@ -0,0 +1,210 @@
1
+ ---
2
+ name: gitstow
3
+ description: >
4
+ ALWAYS use this skill when the user wants to clone, add, or download a git
5
+ repository — gitstow replaces raw `git clone`. Also activate for: "update repos",
6
+ "pull all", "list repos", "check repo status", "freeze/tag a repo", "search across
7
+ repos", "which repos need pushing", or any multi-repo management. Trigger on any
8
+ git URL (github.com, gitlab.com, etc.) or owner/repo shorthand.
9
+ allowed-tools: Bash(gitstow *), Read
10
+ ---
11
+
12
+ # gitstow — Repo Manager Operator Guide
13
+
14
+ You are an expert operator of `gitstow`, a CLI tool that replaces `git clone` and manages repos across multiple workspaces. **Every repo should be added via `gitstow add`, never raw `git clone`** — this ensures it's tracked, organized, and visible in status dashboards.
15
+
16
+ ## Key Concept: Workspaces
17
+
18
+ gitstow organizes repos into **workspaces** — directories with a label, layout mode, and optional auto-tags:
19
+
20
+ - **structured** layout: `workspace/owner/repo/` (good for open-source collections)
21
+ - **flat** layout: `workspace/repo/` (good for active projects)
22
+
23
+ Use `-w <label>` on any command to filter to a specific workspace.
24
+
25
+ ## Cardinal Rule: Never Use Raw `git clone`
26
+
27
+ When the user asks to clone, download, or get a repo, **always use `gitstow add`**. Never fall back to `git clone` — even for a single repo. `gitstow add` clones AND tracks the repo, so it appears in status dashboards and bulk operations.
28
+
29
+ ```bash
30
+ # WRONG — repo cloned but invisible to gitstow
31
+ git clone https://github.com/owner/repo
32
+
33
+ # RIGHT — repo tracked, organized into workspace, visible everywhere
34
+ gitstow add owner/repo
35
+ gitstow -w active add owner/repo # clone into a specific workspace
36
+ ```
37
+
38
+ ## Before Running Any Command
39
+
40
+ **Pre-flight check** — on first use in a session, verify gitstow is available:
41
+
42
+ ```bash
43
+ gitstow --version
44
+ ```
45
+
46
+ If not installed: suggest `pip install gitstow` or `pipx install gitstow`.
47
+ If installed but not configured (no `~/.gitstow/config.yaml`): guide with `gitstow onboard`.
48
+
49
+ ## Core Principle: Use --json for Machine Output
50
+
51
+ When running gitstow commands programmatically, always use `--json --quiet` flags to parse structured output. Present a clean summary to the user, not raw JSON.
52
+
53
+ For commands shown to the user to run themselves, use the human-readable form (no --json).
54
+
55
+ ## Command Decision Tree
56
+
57
+ | User wants to... | Command |
58
+ |---|---|
59
+ | Add/clone a repo | `gitstow add "owner/repo"` or `gitstow add "url"` |
60
+ | Add to specific workspace | `gitstow -w active add "owner/repo"` |
61
+ | Add multiple repos | `gitstow add repo1 repo2 repo3` |
62
+ | Update all repos | `gitstow pull` |
63
+ | Update one workspace | `gitstow -w oss pull` |
64
+ | Update repos by tag | `gitstow pull --tag ai` |
65
+ | List all repos | `gitstow list` |
66
+ | List one workspace | `gitstow -w active list` |
67
+ | Search repos | `gitstow list searchterm` |
68
+ | Check git status | `gitstow status` |
69
+ | Status for one workspace | `gitstow -w active status` |
70
+ | See dirty repos only | `gitstow status --dirty` |
71
+ | Freeze a repo | `gitstow repo freeze owner/repo` |
72
+ | Unfreeze a repo | `gitstow repo unfreeze owner/repo` |
73
+ | Tag a repo | `gitstow repo tag owner/repo tagname` |
74
+ | Remove a tag | `gitstow repo untag owner/repo tagname` |
75
+ | List all tags | `gitstow repo tags` |
76
+ | Repo details | `gitstow repo info owner/repo` |
77
+ | Remove a repo | `gitstow remove owner/repo` |
78
+ | Adopt existing repos | `gitstow migrate /path/to/repo` |
79
+ | Run command in all repos | `gitstow exec -- git log -1 --oneline` |
80
+ | Search across all repos | `gitstow search "pattern" --glob "*.py"` |
81
+ | Open in editor | `gitstow open owner/repo` |
82
+ | Open in browser | `gitstow open owner/repo --browser` |
83
+ | Collection stats | `gitstow stats` |
84
+ | Export collection | `gitstow collection export -o repos.yaml` |
85
+ | Import collection | `gitstow collection import repos.yaml` |
86
+ | See current config | `gitstow config show` |
87
+ | Change settings | `gitstow config set key value` |
88
+ | List workspaces | `gitstow workspace list` |
89
+ | Add a workspace | `gitstow workspace add ~/path --label name --layout flat` |
90
+ | Remove a workspace | `gitstow workspace remove name` |
91
+ | Scan workspace for repos | `gitstow workspace scan name` |
92
+ | Run setup wizard | `gitstow onboard` |
93
+ | Health check | `gitstow doctor` |
94
+
95
+ ## URL Shorthand
96
+
97
+ gitstow supports shorthand — GitHub is the default host:
98
+
99
+ ```bash
100
+ # These are equivalent:
101
+ gitstow add anthropic/claude-code
102
+ gitstow add https://github.com/anthropic/claude-code
103
+ ```
104
+
105
+ For non-GitHub repos, use full URLs:
106
+ ```bash
107
+ gitstow add https://gitlab.com/group/project
108
+ gitstow add git@bitbucket.org:owner/repo.git
109
+ ```
110
+
111
+ ## Common Workflows
112
+
113
+ ### "Add this repo" / "Clone this"
114
+ ```bash
115
+ gitstow add "owner/repo" --json --quiet
116
+ ```
117
+ Parse the JSON result and tell the user: repo name, where it was cloned, success/failure.
118
+
119
+ ### "Add to my active projects workspace"
120
+ ```bash
121
+ gitstow -w active add "owner/repo" --json --quiet
122
+ ```
123
+
124
+ ### "Update everything" / "Pull all repos"
125
+ ```bash
126
+ gitstow pull --json --quiet
127
+ ```
128
+ Parse results and summarize: N pulled, N up to date, N skipped, N errors.
129
+
130
+ ### "What repos do I have?"
131
+ ```bash
132
+ gitstow list --json
133
+ ```
134
+ Present as a clean grouped list. Mention total count, frozen repos, and tags.
135
+
136
+ ### "Which repos need pushing?" / "What's dirty?"
137
+ ```bash
138
+ gitstow status --dirty --json
139
+ ```
140
+
141
+ ### "Show me just my active project status"
142
+ ```bash
143
+ gitstow -w active status --json
144
+ ```
145
+
146
+ ### "Freeze this repo" / "Don't update this anymore"
147
+ ```bash
148
+ gitstow repo freeze owner/repo
149
+ ```
150
+
151
+ ### "What's the status of X?"
152
+ ```bash
153
+ gitstow repo info owner/repo --json
154
+ ```
155
+
156
+ ### "Search for something across all repos"
157
+ ```bash
158
+ gitstow search "pattern" --json --quiet
159
+ ```
160
+
161
+ ### "Run this command in every repo"
162
+ ```bash
163
+ gitstow exec --json -- git log -1 --oneline
164
+ ```
165
+
166
+ ### "How much disk space do my repos use?"
167
+ ```bash
168
+ gitstow stats --json
169
+ ```
170
+
171
+ ### "Share my repo list" / "Export my collection"
172
+ ```bash
173
+ gitstow collection export -o /tmp/repos.yaml
174
+ ```
175
+
176
+ ### "Point gitstow at my existing projects"
177
+ ```bash
178
+ gitstow workspace add ~/labs/projects --label active --layout flat --auto-tag active
179
+ gitstow workspace scan active
180
+ ```
181
+
182
+ ## Bulk Operations from a File
183
+
184
+ ```bash
185
+ cat > /tmp/repos.txt << 'EOF'
186
+ anthropic/claude-code
187
+ facebook/react
188
+ torvalds/linux
189
+ EOF
190
+
191
+ cat /tmp/repos.txt | gitstow add --quiet --json
192
+ ```
193
+
194
+ ## Safety Rules
195
+
196
+ 1. **Always quote URLs** in shell commands
197
+ 2. **Don't run `gitstow onboard`** without telling the user — it's interactive
198
+ 3. **Don't run `gitstow remove --delete`** without explicit user confirmation — it deletes files
199
+ 4. **Use `--json --quiet`** when parsing output programmatically
200
+ 5. **Check `gitstow doctor`** if something seems broken
201
+
202
+ ## Note on MCP Server
203
+
204
+ An optional MCP server exists (`pip install gitstow[mcp]`, run `gitstow-mcp`) for AI tools that can't run CLI commands (Claude Desktop, Cursor). **You don't need it** — this skill gives you full access to all gitstow commands via Bash. The MCP server provides the same operations but costs tokens in every conversation even when idle.
205
+
206
+ ## File Locations
207
+
208
+ - Config: `~/.gitstow/config.yaml`
209
+ - Repo metadata: `~/.gitstow/repos.yaml` (central, nested by workspace)
210
+ - Repos: across configured workspaces (default first workspace: `~/oss/`)
@@ -0,0 +1 @@
1
+ """gitstow TUI — interactive terminal dashboard built with Textual."""
gitstow/tui/app.py ADDED
@@ -0,0 +1,384 @@
1
+ """gitstow TUI — main application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Container, Horizontal
8
+ from textual.widgets import DataTable, Footer, Header, Static, Input
9
+ from textual.screen import ModalScreen
10
+ from textual import work
11
+
12
+ from gitstow.core.config import load_config, Workspace
13
+ from gitstow.core.git import get_status, is_git_repo, pull as git_pull, RepoStatus
14
+ from gitstow.core.repo import Repo, RepoStore
15
+
16
+
17
+ class RepoDetailScreen(ModalScreen):
18
+ """Modal screen showing detailed info for a repo."""
19
+
20
+ BINDINGS = [
21
+ Binding("escape", "dismiss", "Close"),
22
+ Binding("f", "toggle_freeze", "Freeze/Unfreeze"),
23
+ ]
24
+
25
+ def __init__(self, repo: Repo, status: RepoStatus | None, ws: Workspace):
26
+ super().__init__()
27
+ self.repo = repo
28
+ self.repo_status = status
29
+ self.ws = ws
30
+
31
+ def compose(self) -> ComposeResult:
32
+ frozen_str = "YES" if self.repo.frozen else "no"
33
+ tags_str = ", ".join(self.repo.tags) if self.repo.tags else "none"
34
+ branch = self.repo_status.branch if self.repo_status else "unknown"
35
+ status_str = self.repo_status.status_symbol if self.repo_status else "?"
36
+
37
+ info = (
38
+ f"[bold]{self.repo.key}[/bold] [dim]({self.repo.workspace})[/dim]\n\n"
39
+ f" Remote: {self.repo.remote_url}\n"
40
+ f" Path: {self.repo.get_path(self.ws.get_path())}\n"
41
+ f" Branch: {branch}\n"
42
+ f" Status: {status_str}\n"
43
+ f" Frozen: {frozen_str}\n"
44
+ f" Tags: {tags_str}\n"
45
+ f" Workspace: {self.repo.workspace}\n"
46
+ f" Added: {self.repo.added or 'unknown'}\n"
47
+ f" Last pulled: {self.repo.last_pulled or 'never'}\n\n"
48
+ f" [dim]Press [bold]f[/bold] to toggle freeze, [bold]Escape[/bold] to close[/dim]"
49
+ )
50
+ yield Container(
51
+ Static(info, id="detail-content"),
52
+ id="detail-modal",
53
+ )
54
+
55
+ def action_toggle_freeze(self) -> None:
56
+ store = RepoStore()
57
+ store.update(self.repo.key, workspace=self.repo.workspace, frozen=not self.repo.frozen)
58
+ self.repo.frozen = not self.repo.frozen
59
+ self.dismiss(True) # Signal refresh needed
60
+
61
+
62
+ class GitstowApp(App):
63
+ """gitstow interactive dashboard."""
64
+
65
+ TITLE = "gitstow"
66
+ SUB_TITLE = "repo library manager"
67
+
68
+ CSS = """
69
+ #header-bar {
70
+ dock: top;
71
+ height: 1;
72
+ background: $primary;
73
+ color: $text;
74
+ padding: 0 1;
75
+ }
76
+ #filter-bar {
77
+ dock: top;
78
+ height: 3;
79
+ padding: 0 1;
80
+ }
81
+ #filter-input {
82
+ width: 40;
83
+ }
84
+ #summary-bar {
85
+ dock: bottom;
86
+ height: 1;
87
+ background: $surface;
88
+ color: $text-muted;
89
+ padding: 0 1;
90
+ }
91
+ #detail-modal {
92
+ align: center middle;
93
+ width: 70;
94
+ height: 20;
95
+ border: tall $primary;
96
+ background: $surface;
97
+ padding: 1 2;
98
+ }
99
+ DataTable {
100
+ height: 1fr;
101
+ }
102
+ """
103
+
104
+ BINDINGS = [
105
+ Binding("q", "quit", "Quit"),
106
+ Binding("r", "refresh", "Refresh"),
107
+ Binding("p", "pull_all", "Pull All"),
108
+ Binding("P", "pull_selected", "Pull Selected", show=False),
109
+ Binding("f", "toggle_freeze", "Freeze/Unfreeze"),
110
+ Binding("w", "cycle_workspace", "Workspace"),
111
+ Binding("t", "cycle_tag", "Tag"),
112
+ Binding("enter", "show_detail", "Details"),
113
+ Binding("/", "focus_filter", "Filter"),
114
+ Binding("escape", "clear_filter", "Clear Filter"),
115
+ ]
116
+
117
+ def __init__(self):
118
+ super().__init__()
119
+ self.settings = load_config()
120
+ self.store = RepoStore()
121
+ self._ws_map: dict[str, Workspace] = {} # global_key -> workspace
122
+ self._repos: list[Repo] = []
123
+ self._statuses: dict[str, RepoStatus] = {}
124
+ self._filter = ""
125
+ self._ws_filter: str = "" # "" = all workspaces
126
+ self._tag_filter: str = "" # "" = all tags
127
+
128
+ def compose(self) -> ComposeResult:
129
+ yield Header()
130
+ yield Horizontal(
131
+ Static(" Filter: ", id="filter-label"),
132
+ Input(placeholder="type to filter...", id="filter-input"),
133
+ id="filter-bar",
134
+ )
135
+ yield DataTable(id="repo-table")
136
+ yield Static("Loading...", id="summary-bar")
137
+ yield Footer()
138
+
139
+ def on_mount(self) -> None:
140
+ table = self.query_one("#repo-table", DataTable)
141
+ table.cursor_type = "row"
142
+ table.add_columns(
143
+ "Repo", "Branch", "Status", "Ahead/Behind",
144
+ "Frozen", "Tags", "Last Pulled",
145
+ )
146
+ self.load_repos()
147
+
148
+ def on_input_changed(self, event: Input.Changed) -> None:
149
+ if event.input.id == "filter-input":
150
+ self._filter = event.value.lower()
151
+ self._refresh_table()
152
+
153
+ def action_focus_filter(self) -> None:
154
+ self.query_one("#filter-input", Input).focus()
155
+
156
+ def action_clear_filter(self) -> None:
157
+ inp = self.query_one("#filter-input", Input)
158
+ inp.value = ""
159
+ self._filter = ""
160
+ self._refresh_table()
161
+ self.query_one("#repo-table", DataTable).focus()
162
+
163
+ @work(thread=True)
164
+ def load_repos(self) -> None:
165
+ """Load repos and their statuses in a background thread."""
166
+ self.store.load()
167
+ self._repos = self.store.list_all()
168
+
169
+ # Build workspace map
170
+ self._ws_map = {}
171
+ for repo in self._repos:
172
+ ws = self.settings.get_workspace(repo.workspace)
173
+ if ws:
174
+ self._ws_map[repo.global_key] = ws
175
+
176
+ for repo in self._repos:
177
+ ws = self._ws_map.get(repo.global_key)
178
+ if not ws:
179
+ continue
180
+ path = repo.get_path(ws.get_path())
181
+ if path.exists() and is_git_repo(path):
182
+ self._statuses[repo.global_key] = get_status(path)
183
+
184
+ self.call_from_thread(self._refresh_table)
185
+
186
+ def _refresh_table(self) -> None:
187
+ table = self.query_one("#repo-table", DataTable)
188
+ table.clear()
189
+
190
+ filtered = self._repos
191
+ if self._ws_filter:
192
+ filtered = [r for r in filtered if r.workspace == self._ws_filter]
193
+ if self._tag_filter:
194
+ filtered = [r for r in filtered if self._tag_filter in r.tags]
195
+ if self._filter:
196
+ filtered = [
197
+ r for r in filtered
198
+ if self._filter in r.key.lower()
199
+ or any(self._filter in t for t in r.tags)
200
+ or self._filter in r.owner.lower()
201
+ ]
202
+
203
+ for repo in filtered:
204
+ status = self._statuses.get(repo.global_key)
205
+
206
+ branch = status.branch if status else "?"
207
+ status_str = status.status_symbol if status else "?"
208
+ ab = status.ahead_behind_str if status else "?"
209
+ frozen = "❄" if repo.frozen else ""
210
+ tags = ", ".join(repo.tags) if repo.tags else ""
211
+ pulled = self._format_pulled(repo.last_pulled)
212
+
213
+ table.add_row(
214
+ repo.key, branch, status_str, ab,
215
+ frozen, tags, pulled,
216
+ key=repo.global_key,
217
+ )
218
+
219
+ # Summary
220
+ total = len(filtered)
221
+ clean = sum(1 for r in filtered if self._statuses.get(r.global_key, None) and self._statuses[r.global_key].clean)
222
+ frozen = sum(1 for r in filtered if r.frozen)
223
+ dirty = total - clean - frozen
224
+ summary = f" {total} repos | {clean} clean | {dirty} dirty | {frozen} frozen"
225
+ if self._ws_filter:
226
+ summary += f" | ws: {self._ws_filter}"
227
+ if self._tag_filter:
228
+ summary += f" | tag: {self._tag_filter}"
229
+ if self._filter:
230
+ summary += f" | filter: '{self._filter}'"
231
+ self.query_one("#summary-bar", Static).update(summary)
232
+
233
+ def action_refresh(self) -> None:
234
+ self.query_one("#summary-bar", Static).update(" Refreshing...")
235
+ self.load_repos()
236
+
237
+ @work(thread=True)
238
+ def action_pull_all(self) -> None:
239
+ """Pull all unfrozen repos."""
240
+ self.call_from_thread(
241
+ lambda: self.query_one("#summary-bar", Static).update(" Pulling...")
242
+ )
243
+
244
+ from datetime import datetime
245
+ pulled = 0
246
+ for repo in self._repos:
247
+ if repo.frozen:
248
+ continue
249
+ ws = self._ws_map.get(repo.global_key)
250
+ if not ws:
251
+ continue
252
+ path = repo.get_path(ws.get_path())
253
+ if not path.exists() or not is_git_repo(path):
254
+ continue
255
+ status = self._statuses.get(repo.global_key)
256
+ if status and not status.clean:
257
+ continue
258
+
259
+ result = git_pull(path)
260
+ if result.success:
261
+ pulled += 1
262
+ self.store.update(repo.key, workspace=repo.workspace, last_pulled=datetime.now().isoformat())
263
+
264
+ self.call_from_thread(self.load_repos)
265
+ self.call_from_thread(
266
+ lambda: self.notify(f"Pulled {pulled} repos")
267
+ )
268
+
269
+ def action_cycle_workspace(self) -> None:
270
+ """Cycle workspace filter: all → ws1 → ws2 → ... → all."""
271
+ labels = [ws.label for ws in self.settings.get_workspaces()]
272
+ if not labels:
273
+ return
274
+ if not self._ws_filter:
275
+ self._ws_filter = labels[0]
276
+ else:
277
+ try:
278
+ idx = labels.index(self._ws_filter)
279
+ self._ws_filter = labels[idx + 1] if idx + 1 < len(labels) else ""
280
+ except ValueError:
281
+ self._ws_filter = ""
282
+ self._refresh_table()
283
+ label = self._ws_filter or "all"
284
+ self.notify(f"Workspace: {label}")
285
+
286
+ def action_cycle_tag(self) -> None:
287
+ """Cycle tag filter: all → tag1 → tag2 → ... → all."""
288
+ all_tags = sorted(self.store.all_tags())
289
+ if not all_tags:
290
+ return
291
+ if not self._tag_filter:
292
+ self._tag_filter = all_tags[0]
293
+ else:
294
+ try:
295
+ idx = all_tags.index(self._tag_filter)
296
+ self._tag_filter = all_tags[idx + 1] if idx + 1 < len(all_tags) else ""
297
+ except ValueError:
298
+ self._tag_filter = ""
299
+ self._refresh_table()
300
+ label = self._tag_filter or "all"
301
+ self.notify(f"Tag: {label}")
302
+
303
+ @work(thread=True)
304
+ def action_pull_selected(self) -> None:
305
+ """Pull only the selected repo."""
306
+ repo, ws = self.call_from_thread(self._get_selected_repo)
307
+ if not repo or not ws:
308
+ return
309
+ if repo.frozen:
310
+ self.call_from_thread(lambda: self.notify(f"{repo.key} is frozen", severity="warning"))
311
+ return
312
+ path = repo.get_path(ws.get_path())
313
+ if not path.exists() or not is_git_repo(path):
314
+ self.call_from_thread(lambda: self.notify(f"{repo.key} not on disk", severity="error"))
315
+ return
316
+ self.call_from_thread(
317
+ lambda: self.query_one("#summary-bar", Static).update(f" Pulling {repo.key}...")
318
+ )
319
+ result = git_pull(path)
320
+ if result.success:
321
+ from datetime import datetime
322
+ self.store.update(repo.key, workspace=repo.workspace, last_pulled=datetime.now().isoformat())
323
+ self.call_from_thread(lambda: self.notify(f"Pulled {repo.key}"))
324
+ else:
325
+ self.call_from_thread(lambda: self.notify(f"Failed: {result.error}", severity="error"))
326
+ self.call_from_thread(self.load_repos)
327
+
328
+ def _get_selected_repo(self) -> tuple[Repo | None, Workspace | None]:
329
+ """Get the repo and workspace for the currently selected table row."""
330
+ table = self.query_one("#repo-table", DataTable)
331
+ if table.cursor_row is None:
332
+ return None, None
333
+ row_key = table.get_row_at(table.cursor_row)
334
+ global_key = str(row_key.value) if row_key else None
335
+ if not global_key:
336
+ return None, None
337
+ # Find repo by global_key
338
+ for repo in self._repos:
339
+ if repo.global_key == global_key:
340
+ ws = self._ws_map.get(global_key)
341
+ return repo, ws
342
+ return None, None
343
+
344
+ def action_toggle_freeze(self) -> None:
345
+ repo, ws = self._get_selected_repo()
346
+ if repo:
347
+ self.store.update(repo.key, workspace=repo.workspace, frozen=not repo.frozen)
348
+ repo.frozen = not repo.frozen
349
+ self._refresh_table()
350
+ action = "Froze" if repo.frozen else "Unfroze"
351
+ self.notify(f"{action} {repo.key}")
352
+
353
+ def action_show_detail(self) -> None:
354
+ repo, ws = self._get_selected_repo()
355
+ if repo and ws:
356
+ status = self._statuses.get(repo.global_key)
357
+
358
+ def on_dismiss(refresh_needed: bool) -> None:
359
+ if refresh_needed:
360
+ self.store.load()
361
+ self._repos = self.store.list_all()
362
+ self._refresh_table()
363
+
364
+ self.push_screen(
365
+ RepoDetailScreen(repo, status, ws),
366
+ callback=on_dismiss,
367
+ )
368
+
369
+ @staticmethod
370
+ def _format_pulled(iso_str: str) -> str:
371
+ if not iso_str:
372
+ return "never"
373
+ from datetime import datetime
374
+ try:
375
+ dt = datetime.fromisoformat(iso_str)
376
+ diff = (datetime.now() - dt).total_seconds()
377
+ if diff < 3600:
378
+ return f"{int(diff/60)}m ago"
379
+ elif diff < 86400:
380
+ return f"{int(diff/3600)}h ago"
381
+ else:
382
+ return f"{int(diff/86400)}d ago"
383
+ except (ValueError, TypeError):
384
+ return "?"