{
  "collection": "dev.atuan.post",
  "did": "did:plc:cqqdxajzet74xn2ktz5y56rc",
  "posts": [
    {
      "uri": "at://did:plc:cqqdxajzet74xn2ktz5y56rc/dev.atuan.post/3mv5i3nwscq2i",
      "cid": "bafyreidtmompt36o5nyt26eifq5dzpmcofjyup4uubehmpienlbwi2lpr4",
      "value": {
        "$type": "dev.atuan.post",
        "title": "Resolving merge conflicts with vim-fugitive",
        "rawText": "1. The mental model\nA conflicted file is three files in the index:\n| Stage | Fugitive path | Name   | What it is               |\n| ----- | ------------- | ------ | ------------------------ |\n| 1     | :1:%        | base   | common ancestor          |\n| 2     | //2         | ours   | the branch you land ON   |\n| 3     | //3         | theirs | the change being applied |\nPosition tells you which is which — never the label.\n`\n>>>>> (anything)     BOTTOM = stage :3 = theirs\n`\nText after the marker is just a name. Normally HEAD / branch name; if an\nexternal mergetool ran it can be junk like .mergefileOy7xOk. Ignore it,\ncount position.\nWhat ours/theirs actually mean\n| Operation     | ours (top)                 | theirs (bottom)              |\n| ------------- | -------------------------- | ---------------------------- |\n| merge       | your current branch        | the branch being merged in   |\n| rebase      | the branch you land on | your own commit replayed |\n| cherry-pick | your current branch        | the commit being picked      |\nRebase inverts because git checks out onto first, then replays your commits\nonto it. During each replay HEAD is the upstream side.\nVS Code wording maps to position: \"Current Change\" = top = ours,\n\"Incoming Change\" = bottom = theirs.\nDon't remember — look\n`vim\n:Gsplit //2     \" entire 'ours' version of this file\n:Gsplit //3     \" entire 'theirs' version\n`\nWhose commits are they, mid-rebase:\n`bash\ngit log -1 --oneline $(cat .git/rebase-merge/onto)   # ours  (//2)\ngit log -1 --oneline REBASE_HEAD                     # theirs (//3)\n`\n2. Three-way diff workflow\n`vim\n:Git mergetool -y     \" every conflict in its own tab, 3-way diff ready\n`\nOr manually — cursor in the working-copy buffer:\n`vim\n:Gdiffsplit!          \" the ! is what makes it three-way\n`\nWindows: //2 (ours) left, working copy centre, //3 (theirs) right.\nEdit the centre buffer — that's the one that gets kept.\n| Key         | Does                               |\n| ----------- | ---------------------------------- |\n| ]c / [c | next / previous hunk               |\n| d2o       | take this hunk from ours (//2)   |\n| d3o       | take this hunk from theirs (//3) |\n| dq        | close side buffers, keep result    |\nd2o/d3o exist because plain do/dp are ambiguous with three buffers.\nRange form: :'diffget //3\n3. Quickfix workflow (no diff windows)\n`vim\n:Git mergetool     \" NO -y: quickfix list only, lands you in the real file\n:cnext / :cprev    \" walk conflicts\n:copen             \" see the list\n`\n:Git! mergetool loads the list without jumping.\nGotcha: if you land in a buffer named fugitive://... it is nomodifiable\nby design — that's the index version, not your file. Check with:\n`vim\n:echo bufname('%')\n:echo &modifiable\n`\nFix: :e path/to/file to get the real one.\nAlso: do/dp only work in diff mode. In a normal buffer there is no \"apply\"\nkey — you delete lines yourself. That's the whole job.\n4. Editing markers by hand\nCursor on the `    delete >>>>>>dd   remove closing marker\n`\nKeep ours (top):\n`\ndd                remove      jump to separator\nd/^>>>>>>>    delete separator through theirs content\ndd                remove closing marker\n`\nAs mappings — cursor anywhere inside the conflict:\n`vim\nnnoremap gu ?^dd/^=======d/^>>>>>>>dd\nnnoremap gt ?^d/^=======dd/^>>>>>>>dd\n`\ngu = take ours, gt = take theirs.\nWhole file at once:\n`vim\n:g/^=======$/,/^>>>>>>>/d | g/^>>>>>>/d     \" keep theirs everywhere\n`\nBy line number — delete the higher range first so numbers don't shift:\n`vim\n:18d\n:9,13d\n`\n5. Whole-file resolution\n`vim\n:Gread //2     \" replace buffer with ours\n:Gread //3     \" replace buffer with theirs\n:Gwrite        \" write + git add = resolved\n`\nu undoes a bad :Gread.\nCareful: :Gread takes that stage's entire file, discarding anything\nauto-merged from the other side. Only safe when the two stages differ solely\ninside the conflict. Verify first:\n`bash\ngit diff --no-index >>>>>>`. Pick one and stay consistent:\nHand-editing markers → keep plain merge style.\nLiving in :Gdiffsplit! → take zdiff3.\n8. Repairing junk conflict labels\nIf markers say .mergefileXXXXXX instead of HEAD/branch, an external\nmergetool ran and left temp names. Regenerate from the index:\n`bash\ngit checkout --conflict=merge -- path/to/file\n`\nDiscards hand-edits in that file. Check git config merge.tool — if it's\nmisconfigured you'll keep getting unlabeled conflicts.\nAfter the rebase: \"your branch and origin have diverged\"\n`\nYour branch and 'origin/staging' have diverged,\nand have 5 and 14 different commits each, respectively.\n  (use \"git pull\" if you want to integrate the remote branch with yours)\n`\nDo not reflexively reach for push --force-with-lease. Diagnose first.\nStep 1 — find out what each side actually holds\n`bash\ngit fetch origin\nyour commits the remote lacks\ngit log --oneline --format='%h %an | %s' origin/staging..staging\nthe remote's commits you lack\ngit log --oneline --format='%h %an | %s' staging..origin/staging\nwhere you split\ngit log -1 --format='%h %an %ad | %s' --date=short \\\n  $(git merge-base staging origin/staging)\n`\nStep 2 — decide from what you see\n| What the remote-only side contains                   | Verdict                     |\n| -------------------------------------------------------- | --------------------------- |\n| Only your own commits, same subjects, rewritten by rebase | force-push is safe          |\n| Commits by other authors                                  | integrate — never force |\n| Merge commits from other branches, PR merges              | integrate — never force |\n| Anything you can't account for                            | integrate — never force |\n--force-with-lease is not a safety net here. It only verifies nobody\npushed since your last fetch. If you already fetched those commits, the lease\ncheck passes and the push destroys them anyway. It protects against a race,\nnot against you being wrong about what's on the branch.\nWorked example — the case in this repo\nLocal staging had 5 commits, origin/staging had 14 different ones.\nLocal-only (5):\n`\n92e3da45 coderabbitai[bot] | Fix source safety, newsletter redirects, palette pagination\n23ddeca9 coderabbitai[bot] | Add docstrings for palette, source, quiz, newsletter helpers\nf3d9f7d1 Doruk Özer        | color palettes UI and endpoints + query params update\n1c49a105 Doruk Özer        | update mwf email dialog and query params\n5f877c19 Doruk Özer        | implement mwf email trivia route + queries\n`\nRemote-only (14): almost entirely integration history —\nMerge branch 'development' into staging, Merge branch 'main' into staging,\nand PR merges #1250 – #1265. Merge base: bead2ab8.\nForce-pushing would have deleted all 14, including every development merge and\nPR. The three Doruk Özer commits were LOCAL-ONLY — real unpushed work worth\nkeeping.\nConfirm what is and isn't on the remote:\n`bash\nfor c in 5f877c19 1c49a105 f3d9f7d1; do\n  printf '%s : %s\\n' \"$c\" \\\n    \"$(git branch -r --contains $c | grep -q origin/staging && echo on-remote || echo LOCAL-ONLY)\"\ndone\n`\nStep 3 — integrate\nMatch the branch's existing shape. If its history is all merge commits, merge:\n`bash\ngit fetch origin\ngit merge origin/staging\nresolve conflicts (sections 1–6 above)\ngit push                  # plain fast-forward, no force\n`\nRebase instead only when your local commits are unpushed and you want them\nlinear on top:\n`bash\ngit rebase origin/staging\n`\nRebasing a branch other people also push to means everyone else hits this same\ndivergence next. On a shared branch, prefer the merge.\nStep 4 — expect duplicate-commit wrinkles\nIf you rebased commits that already exist on the remote, your rewritten copies\nare different objects. Check whether git will recognise them:\n`bash\ngit show LOCAL_SHA  | git patch-id --stable\ngit show REMOTE_SHA | git patch-id --stable\n`\nIdentical patch-ids → rebase drops the duplicate automatically.\nDifferent → it replays, and you get a near-duplicate commit plus, usually,\n  the same conflict all over again.\nIn the worked example, 23ddeca9/92e3da45 were rebased copies of the remote's\ndb94ca81/99c18e0a, and the patch-ids came out different — the conflict\nresolution (the 3-arg latestTarget signature) was baked into the local copies.\nSo the merge re-raised that exact collision. Resolve it the same way, keep the\n3-arg signature, and accept both commits sitting in history. Redundant, harmless.\nTurn this on so the next repeat resolves itself:\n`bash\ngit config --global rerere.enabled true\n`\nStep 5 — when force-push genuinely is the answer\nOnly after Step 2 clears it: the remote-only commits are yours, rewritten, and\nnobody else builds on the branch.\n`bash\ngit push --force-with-lease=staging:$(git rev-parse origin/staging)\n`\nThe explicit form pins the exact SHA you verified rather than trusting whatever\nyour remote-tracking ref happens to say.\nRecovery\nNothing here is unrecoverable while the reflog lives (90 days by default):\n`bash\ngit reflog                     # find the pre-rebase SHA\ngit reset --hard HEAD@{N}\ngit reflog show origin/staging # remote-tracking ref history too\n`\nIf you force-pushed over someone's commits and still have them locally, push\nthem straight back. If you don't, whoever does have them can.",
        "createdAt": "2026-09-10T06:59:44.157Z",
        "rawMarkdown": "## 1. The mental model\r\n\r\nA conflicted file is **three** files in the index:\r\n\r\n| Stage | Fugitive path | Name   | What it is               |\r\n| ----- | ------------- | ------ | ------------------------ |\r\n| 1     | `:1:%`        | base   | common ancestor          |\r\n| 2     | `//2`         | ours   | the branch you land ON   |\r\n| 3     | `//3`         | theirs | the change being applied |\r\n\r\n**Position tells you which is which — never the label.**\r\n\r\n```\r\n<<<<<<< (anything)     TOP    = stage :2 = ours\r\n=======\r\n>>>>>>> (anything)     BOTTOM = stage :3 = theirs\r\n```\r\n\r\nText after the marker is just a name. Normally `HEAD` / branch name; if an\r\nexternal mergetool ran it can be junk like `.merge_file_Oy7xOk`. Ignore it,\r\ncount position.\r\n\r\n### What ours/theirs actually mean\r\n\r\n| Operation     | ours (top)                 | theirs (bottom)              |\r\n| ------------- | -------------------------- | ---------------------------- |\r\n| `merge`       | your current branch        | the branch being merged in   |\r\n| `rebase`      | **the branch you land on** | **your own commit replayed** |\r\n| `cherry-pick` | your current branch        | the commit being picked      |\r\n\r\nRebase inverts because git checks out `onto` first, then replays your commits\r\nonto it. During each replay HEAD *is* the upstream side.\r\n\r\nVS Code wording maps to position: \"Current Change\" = top = ours,\r\n\"Incoming Change\" = bottom = theirs.\r\n\r\n### Don't remember — look\r\n\r\n```vim\r\n:Gsplit //2     \" entire 'ours' version of this file\r\n:Gsplit //3     \" entire 'theirs' version\r\n```\r\n\r\nWhose commits are they, mid-rebase:\r\n\r\n```bash\r\ngit log -1 --oneline $(cat .git/rebase-merge/onto)   # ours  (//2)\r\ngit log -1 --oneline REBASE_HEAD                     # theirs (//3)\r\n```\r\n\r\n## 2. Three-way diff workflow\r\n\r\n```vim\r\n:Git mergetool -y     \" every conflict in its own tab, 3-way diff ready\r\n```\r\n\r\nOr manually — cursor in the working-copy buffer:\r\n\r\n```vim\r\n:Gdiffsplit!          \" the ! is what makes it three-way\r\n```\r\n\r\nWindows: `//2` (ours) left, working copy centre, `//3` (theirs) right.\r\n**Edit the centre buffer** — that's the one that gets kept.\r\n\r\n| Key         | Does                               |\r\n| ----------- | ---------------------------------- |\r\n| `]c` / `[c` | next / previous hunk               |\r\n| `d2o`       | take this hunk from ours (`//2`)   |\r\n| `d3o`       | take this hunk from theirs (`//3`) |\r\n| `dq`        | close side buffers, keep result    |\r\n\r\n`d2o`/`d3o` exist because plain `do`/`dp` are ambiguous with three buffers.\r\nRange form: `:'<,'>diffget //3`\r\n\r\n## 3. Quickfix workflow (no diff windows)\r\n\r\n```vim\r\n:Git mergetool     \" NO -y: quickfix list only, lands you in the real file\r\n:cnext / :cprev    \" walk conflicts\r\n:copen             \" see the list\r\n```\r\n\r\n`:Git! mergetool` loads the list without jumping.\r\n\r\n**Gotcha:** if you land in a buffer named `fugitive://...` it is `nomodifiable`\r\nby design — that's the index version, not your file. Check with:\r\n\r\n```vim\r\n:echo bufname('%')\r\n:echo &modifiable\r\n```\r\n\r\nFix: `:e path/to/file` to get the real one.\r\n\r\nAlso: `do`/`dp` only work in diff mode. In a normal buffer there is no \"apply\"\r\nkey — you delete lines yourself. That's the whole job.\r\n\r\n## 4. Editing markers by hand\r\n\r\nCursor on the `<<<<<<<` line.\r\n\r\n**Keep theirs (bottom):**\r\n\r\n```\r\nd/^=======<CR>    delete <<<<<<< through ours content\r\ndd                remove the ======= line\r\n/^>>>>>>><CR>dd   remove closing marker\r\n```\r\n\r\n**Keep ours (top):**\r\n\r\n```\r\ndd                remove <<<<<<< line\r\n/^=======<CR>     jump to separator\r\nd/^>>>>>>><CR>    delete separator through theirs content\r\ndd                remove closing marker\r\n```\r\n\r\nAs mappings — cursor anywhere inside the conflict:\r\n\r\n```vim\r\nnnoremap <leader>gu ?^<<<<<<<<CR>dd/^=======<CR>d/^>>>>>>><CR>dd\r\nnnoremap <leader>gt ?^<<<<<<<<CR>d/^=======<CR>dd/^>>>>>>><CR>dd\r\n```\r\n\r\n`gu` = take ours, `gt` = take theirs.\r\n\r\nWhole file at once:\r\n\r\n```vim\r\n:g/^=======$/,/^>>>>>>>/d | g/^<<<<<<</d     \" keep ours everywhere\r\n:g/^<<<<<<</,/^=======$/d | g/^>>>>>>>/d     \" keep theirs everywhere\r\n```\r\n\r\nBy line number — delete the **higher** range first so numbers don't shift:\r\n\r\n```vim\r\n:18d\r\n:9,13d\r\n```\r\n\r\n## 5. Whole-file resolution\r\n\r\n```vim\r\n:Gread //2     \" replace buffer with ours\r\n:Gread //3     \" replace buffer with theirs\r\n:Gwrite        \" write + git add = resolved\r\n```\r\n\r\n`u` undoes a bad `:Gread`.\r\n\r\n**Careful:** `:Gread` takes that stage's *entire* file, discarding anything\r\nauto-merged from the other side. Only safe when the two stages differ solely\r\ninside the conflict. Verify first:\r\n\r\n```bash\r\ngit diff --no-index <(git show :2:PATH) <(git show :3:PATH)\r\n```\r\n\r\n## 6. Finishing\r\n\r\n```vim\r\n:Gwrite                  \" from the working-copy buffer: write + stage\r\n:Git                     \" status; file moves to staged section\r\n:Git rebase --continue   \" fugitive opens the message buffer; :wq\r\n```\r\n\r\n`:Git rebase --abort` costs nothing when lost.\r\n\r\n## 7. Config worth setting\r\n\r\n```bash\r\ngit config --global rerere.enabled true        # replay past resolutions\r\ngit config --global mergetool.keepBackup false # stop .orig litter\r\n```\r\n\r\n`merge.conflictStyle zdiff3` adds the common ancestor into the markers between\r\n`|||||||` and `=======`. Much more readable in the three-way diff — but it\r\n**breaks every marker-deleting recipe above**, since \"keep ours\" must then\r\ndelete `|||||||` through `>>>>>>>`. Pick one and stay consistent:\r\n\r\n- Hand-editing markers → keep plain `merge` style.\r\n- Living in `:Gdiffsplit!` → take `zdiff3`.\r\n\r\n## 8. Repairing junk conflict labels\r\n\r\nIf markers say `.merge_file_XXXXXX` instead of `HEAD`/branch, an external\r\nmergetool ran and left temp names. Regenerate from the index:\r\n\r\n```bash\r\ngit checkout --conflict=merge -- path/to/file\r\n```\r\n\r\nDiscards hand-edits in that file. Check `git config merge.tool` — if it's\r\nmisconfigured you'll keep getting unlabeled conflicts.\r\n\r\n---\r\n\r\n# After the rebase: \"your branch and origin have diverged\"\r\n\r\n```\r\nYour branch and 'origin/staging' have diverged,\r\nand have 5 and 14 different commits each, respectively.\r\n  (use \"git pull\" if you want to integrate the remote branch with yours)\r\n```\r\n\r\n**Do not reflexively reach for `push --force-with-lease`.** Diagnose first.\r\n\r\n## Step 1 — find out what each side actually holds\r\n\r\n```bash\r\ngit fetch origin\r\n\r\n# your commits the remote lacks\r\ngit log --oneline --format='%h %an | %s' origin/staging..staging\r\n\r\n# the remote's commits you lack\r\ngit log --oneline --format='%h %an | %s' staging..origin/staging\r\n\r\n# where you split\r\ngit log -1 --format='%h %an %ad | %s' --date=short \\\r\n  $(git merge-base staging origin/staging)\r\n```\r\n\r\n## Step 2 — decide from what you see\r\n\r\n| What the **remote-only** side contains                   | Verdict                     |\r\n| -------------------------------------------------------- | --------------------------- |\r\n| Only your own commits, same subjects, rewritten by rebase | force-push is safe          |\r\n| Commits by other authors                                  | **integrate — never force** |\r\n| Merge commits from other branches, PR merges              | **integrate — never force** |\r\n| Anything you can't account for                            | **integrate — never force** |\r\n\r\n`--force-with-lease` is **not** a safety net here. It only verifies nobody\r\npushed since your last fetch. If you already fetched those commits, the lease\r\ncheck passes and the push destroys them anyway. It protects against a *race*,\r\nnot against you being wrong about what's on the branch.\r\n\r\n## Worked example — the case in this repo\r\n\r\nLocal `staging` had 5 commits, `origin/staging` had 14 different ones.\r\n\r\n**Local-only (5):**\r\n\r\n```\r\n92e3da45 coderabbitai[bot] | Fix source safety, newsletter redirects, palette pagination\r\n23ddeca9 coderabbitai[bot] | Add docstrings for palette, source, quiz, newsletter helpers\r\nf3d9f7d1 Doruk Özer        | color palettes UI and endpoints + query params update\r\n1c49a105 Doruk Özer        | update mwf email dialog and query params\r\n5f877c19 Doruk Özer        | implement mwf email trivia route + queries\r\n```\r\n\r\n**Remote-only (14):** almost entirely integration history —\r\n`Merge branch 'development' into staging`, `Merge branch 'main' into staging`,\r\nand PR merges **#1250 – #1265**. Merge base: `bead2ab8`.\r\n\r\nForce-pushing would have deleted all 14, including every development merge and\r\nPR. The three `Doruk Özer` commits were `LOCAL-ONLY` — real unpushed work worth\r\nkeeping.\r\n\r\nConfirm what is and isn't on the remote:\r\n\r\n```bash\r\nfor c in 5f877c19 1c49a105 f3d9f7d1; do\r\n  printf '%s : %s\\n' \"$c\" \\\r\n    \"$(git branch -r --contains $c | grep -q origin/staging && echo on-remote || echo LOCAL-ONLY)\"\r\ndone\r\n```\r\n\r\n## Step 3 — integrate\r\n\r\nMatch the branch's existing shape. If its history is all merge commits, merge:\r\n\r\n```bash\r\ngit fetch origin\r\ngit merge origin/staging\r\n# resolve conflicts (sections 1–6 above)\r\ngit push                  # plain fast-forward, no force\r\n```\r\n\r\nRebase instead only when your local commits are unpushed **and** you want them\r\nlinear on top:\r\n\r\n```bash\r\ngit rebase origin/staging\r\n```\r\n\r\nRebasing a branch other people also push to means everyone else hits this same\r\ndivergence next. On a shared branch, prefer the merge.\r\n\r\n## Step 4 — expect duplicate-commit wrinkles\r\n\r\nIf you rebased commits that already exist on the remote, your rewritten copies\r\nare different objects. Check whether git will recognise them:\r\n\r\n```bash\r\ngit show LOCAL_SHA  | git patch-id --stable\r\ngit show REMOTE_SHA | git patch-id --stable\r\n```\r\n\r\n- **Identical patch-ids** → rebase drops the duplicate automatically.\r\n- **Different** → it replays, and you get a near-duplicate commit plus, usually,\r\n  the same conflict all over again.\r\n\r\nIn the worked example, `23ddeca9`/`92e3da45` were rebased copies of the remote's\r\n`db94ca81`/`99c18e0a`, and the patch-ids came out **different** — the conflict\r\nresolution (the 3-arg `latestTarget` signature) was baked into the local copies.\r\nSo the merge re-raised that exact collision. Resolve it the same way, keep the\r\n3-arg signature, and accept both commits sitting in history. Redundant, harmless.\r\n\r\nTurn this on so the *next* repeat resolves itself:\r\n\r\n```bash\r\ngit config --global rerere.enabled true\r\n```\r\n\r\n## Step 5 — when force-push genuinely is the answer\r\n\r\nOnly after Step 2 clears it: the remote-only commits are yours, rewritten, and\r\nnobody else builds on the branch.\r\n\r\n```bash\r\ngit push --force-with-lease=staging:$(git rev-parse origin/staging)\r\n```\r\n\r\nThe explicit form pins the exact SHA you verified rather than trusting whatever\r\nyour remote-tracking ref happens to say.\r\n\r\n## Recovery\r\n\r\nNothing here is unrecoverable while the reflog lives (90 days by default):\r\n\r\n```bash\r\ngit reflog                     # find the pre-rebase SHA\r\ngit reset --hard HEAD@{N}\r\ngit reflog show origin/staging # remote-tracking ref history too\r\n```\r\n\r\nIf you force-pushed over someone's commits and still have them locally, push\r\nthem straight back. If you don't, whoever does have them can."
      }
    },
    {
      "uri": "at://did:plc:cqqdxajzet74xn2ktz5y56rc/dev.atuan.post/3mv5i4hxdhf2l",
      "cid": "bafyreia6n6yfz5xjlwuxu44qowbvwydzisv3oomb7iytgoa6ofpxrnmo44",
      "value": {
        "$type": "dev.atuan.post",
        "title": "Stacked PRs with `gh stack`",
        "rawText": "Practical notes for working with the github/gh-stack\nextension in this repo. Written against gh stack v0.1.0.\nTrunk in this repo is development, not main. GitHub's default branch is\nmain, so gh stack init would pick the wrong base if you let it guess. Always\npass --base development when starting a stack.\nThe mental model\nA stack is a chain of branches, each based on the one below it, bottom to top:\n`\ndevelopment                          ← trunk\n└── dev/feature-page-update        ← PR targets development\n    └── dev/env-setup-updates        ← PR targets feature-page-update\n        └── dev/task-1234-ui-updates-jan-06\n            └── dev/task-5678-ui-fixes-jan-02\n                └── dev/build-config-update\n`\nTwo consequences worth internalising:\nEvery branch already contains everything below it. If a change lives on\n  env-setup-updates, it is present on every branch above it. There is nothing to\n  \"apply\" upward — that's what the stack is.\ngh stack tracks the order separately from git. Branch position in the\n  stack is stored in local tracking state. Rebasing doesn't reorder the stack; it\n  only changes which commit each branch hangs off.\ngh stack view status icons:\n| Icon | Meaning      |\n| ---- | ------------ |\n| ✓  | PR merged    |\n| ◎  | PR queued    |\n| ○  | PR open      |\n| ⚠  | Needs rebase |\nScenario: starting a new stack\n`bash\ngh stack init --base development my-first-branch\n`\nMultiple layers at once, bottom to top:\n`bash\ngh stack init --base development auth-layer api-routes ui-components\n`\nAdopting branches that already exist (also bottom to top):\n`bash\ngh stack init --base development dev/feat-a dev/feat-b dev/feat-c\n`\nExisting branches are adopted; missing ones are created.\nScenario: adding a branch on top\n`bash\ngh stack top                       # jump to the furthest branch from trunk\ngh stack add dev/my-next-thing\n`\nUse gh stack top, not gh stack up. up moves exactly one layer (gh stack up 3\nfor three) and requires you to know the count; top gets there in one step.\nCreate the branch and commit staged work in one go:\n`bash\ngh stack add -Am \"Add the thing\" dev/my-next-thing\n`\n-A stages everything including untracked files\n-m sets the commit message; omit the branch name and it's auto-generated from\n  the message\nEither way you end up checked out on the new branch, stacked on everything below.\nScenario: committing to a branch in the middle\nThis is the common one. Say you're on dev/task-5678-ui-fixes-jan-02\nand it has two branches above it.\n`bash\ngit add -A && git commit -m \"your message\"\ngh stack rebase --upstack\n`\n--upstack replants only the branches above you. Nothing below changed, so there's\nno reason to touch it.\nNotes:\nCommit first. Rebase requires a clean working tree.\nIf gh stack view shows no ⚠ after committing, you can skip the rebase.\nUse plain gh stack rebase (no flag) when development has moved and you want\n  the new trunk pulled through the whole chain.\nScenario: a branch shows ⚠ Needs rebase\n`bash\ngh stack rebase\ngh stack view        # confirm the ⚠ is gone\n`\nWhat this actually does\n⚠ means the branch is attached to the right parent branch but not to its tip —\nusually because you committed to a lower branch afterwards. Example from this repo:\n`\nbefore:  ...e977eadf (profile header update)  ←  4d26b902  build config\n                    └─ 911decc8 ─ 9ce33918  (two newer commits on task-5678)\nafter:   ...9ce33918 (profile detail page updates)  ←  1a0b64ac  build config\n`\nThe rebase took the build config commit's change, replayed it on top of the current tip,\nand wrote a new commit: 4d26b902 → 1a0b64ac. Same message, same diff, new\nSHA. The branch never moved position in the stack — a rebase rewrites commits onto\na new base, it does not reorder anything.\nThis is why pushing after a rebase needs --force-with-lease, which gh stack push\nand gh stack submit handle for you.\nRebase variants\n| Command                       | Scope                                   |\n| ----------------------------- | --------------------------------------- |\n| gh stack rebase             | Whole stack, including a fetch of trunk |\n| gh stack rebase --upstack   | Current branch → top                    |\n| gh stack rebase --downstack | Trunk → current branch                  |\n| gh stack rebase --no-trunk  | Inter-branch only; skip fetching trunk  |\nIf it conflicts\n`bash\nresolve the files, then:\ngit add\ngh stack rebase --continue\nor bail out — all branches are restored:\ngh stack rebase --abort\n`\nScenario: publishing the stack as PRs\n`bash\ngh stack submit\n`\nOpens a single-screen editor: every branch without a PR is included by default,\ndeselect with the checkbox or ^x, write each title/description, toggle draft\nstate with \"CREATE AS\", then Ctrl+S to submit them all.\nIt pushes all branches, creates the new PRs, fixes the base branch on existing PRs,\nand creates/updates the stack object on GitHub.\n`bash\ngh stack submit --auto     # skip the editor, auto-generated titles (creates drafts)\ngh stack submit --open     # mark new and existing PRs ready for review\n`\nIn a non-interactive terminal --auto is implied.\nPush without touching PRs\n`bash\ngh stack push\n`\nPer-branch --force-with-lease, not atomic — one branch can land while another\nis rejected. Fix the rejected one and re-run; already-pushed branches stay as they\nare.\nScenario: keeping in sync with the remote\n`bash\ngh stack sync\n`\nThe full round trip: fetch → reconcile local stack against GitHub → fast-forward\ntrunk → cascade-rebase onto updated parents → push all branches atomically\n(--force-with-lease --atomic) → sync PR state → link the PRs into a stack when\ntwo or more exist.\nIf PRs were added to the stack on GitHub, their branches are pulled down and\nappended locally. A clean \"remote is ahead\" update happens silently. A genuine\ndivergence prompts you to pick a source of truth; cancelling — or hitting a\ndivergence in a non-interactive terminal — aborts without pushing anything.\nScenario: restructuring the stack\n`bash\ngh stack modify\n`\nInteractive TUI for dropping, folding, inserting, reordering, and renaming\nbranches. Changes are staged and applied together on Ctrl+S.\n`bash\ngh stack modify --continue    # after resolving conflicts\ngh stack modify --abort       # restore the pre-modify state\n`\nRun gh stack submit afterward if the affected branches have PRs.\nScenario: merging\n`bash\ngh stack merge\n`\nGitHub's atomic stack merge: everything up to and including your chosen PR merges\nin one all-or-nothing operation — if any PR can't merge, none do. Interactive\nwizard lets you pick how far up to merge and the merge method.\n`bash\ngh stack merge 123     # merge up to a specific stack or PR number\ngh stack merge --yes   # no prompting\n`\nOnly basic PR state (open, not draft) is checked locally; branch protection and\nrepo rules are evaluated by GitHub at merge time. Bypassing merge requirements is\nnot supported for stacks. If the base branch uses a merge queue, the stack is\nqueued rather than merged directly.\nScenario: picking up someone else's stack, or one of your own\n`bash\ngh stack checkout                 # interactive picker over all stacks, local + GitHub\ngh stack checkout 4               # stack number, then PR number, then branch name\ngh stack checkout 1234            # PR number\ngh stack checkout https://github.com/acme-co/product-web/pull/1234\ngh stack checkout dev/env-setup-updates\n`\nFor a PR not tracked locally, it queries the API, discovers the stack, fetches the\nbranches, and sets up local tracking.\nNavigation\n| Command             | Goes to                                     |\n| ------------------- | ------------------------------------------- |\n| gh stack up [n]   | n branches further from trunk (default 1) |\n| gh stack down [n] | n branches closer to trunk (default 1)    |\n| gh stack top      | Furthest branch from trunk                  |\n| gh stack bottom   | Closest branch to trunk                     |\n| gh stack trunk    | The trunk branch itself                     |\n| gh stack switch   | Interactive picker within the stack         |\nMerged branches are skipped automatically by up and down.\nInspecting\n`bash\ngh stack view            # the default tree view\ngh stack view --short    # one line per branch\ngh stack view --json     # machine-readable\n`\nUseful sanity check that each layer really sits on top of its parent:\n`bash\ngit merge-base --is-ancestor   && echo OK || echo STALE\n`\nTearing down\n`bash\ngh stack unstack            # unstack the active stack on GitHub + drop local tracking\ngh stack unstack 4          # by stack number, from anywhere in the repo\ngh stack unstack --local    # drop local tracking only, leave GitHub alone\n`\nGitHub decides what can be unstacked — PRs that are queued or have auto-merge\nenabled stay stacked, and when any remain the stack is kept.\nCheat sheet\n| I want to…                                | Command                                         |\n| ----------------------------------------- | ----------------------------------------------- |\n| See where I am                            | gh stack view                                 |\n| Start a stack                             | gh stack init --base development      |\n| Add a branch on top                       | gh stack top && gh stack add          |\n| Commit mid-stack and fix what's above     | git commit … then gh stack rebase --upstack |\n| Clear a ⚠                               | gh stack rebase                               |\n| Open/update the PRs                       | gh stack submit                               |\n| Pull in trunk changes and push everything | gh stack sync                                 |\n| Reorder / drop / rename branches          | gh stack modify                               |\n| Merge the chain                           | gh stack merge                                |\nGotchas\nTrunk is development. Pass --base development on init.\nRebasing rewrites SHAs. Expect force-pushes; let gh stack push / submit\n  do them with --force-with-lease rather than running git push -f yourself.\nLocal ≠ published. gh stack rebase never touches the remote. Until you run\n  submit or sync, the stack exists only on your machine.\nClean tree before rebasing. Commit or stash first.\ngh stack push is not atomic; gh stack sync is. Prefer sync when several\n  branches moved.\nRun yarn precommit after a rebase pulls code across branches — the branch\n  you're on may now contain commits it hasn't been type-checked against.",
        "createdAt": "2026-09-10T07:00:11.134Z",
        "rawMarkdown": "Practical notes for working with the [`github/gh-stack`](https://gh.io/stacks)\r\nextension in this repo. Written against **gh stack v0.1.0**.\r\n\r\n> **Trunk in this repo is `development`, not `main`.** GitHub's default branch is\r\n> `main`, so `gh stack init` would pick the wrong base if you let it guess. Always\r\n> pass `--base development` when starting a stack.\r\n\r\n---\r\n\r\n## The mental model\r\n\r\nA stack is a chain of branches, each based on the one below it, bottom to top:\r\n\r\n```\r\ndevelopment                          ← trunk\r\n└── dev/feature-page-update        ← PR targets development\r\n    └── dev/env-setup-updates        ← PR targets feature-page-update\r\n        └── dev/task-1234-ui-updates-jan-06\r\n            └── dev/task-5678-ui-fixes-jan-02\r\n                └── dev/build-config-update\r\n```\r\n\r\nTwo consequences worth internalising:\r\n\r\n- **Every branch already contains everything below it.** If a change lives on\r\n  `env-setup-updates`, it is present on every branch above it. There is nothing to\r\n  \"apply\" upward — that's what the stack _is_.\r\n- **`gh stack` tracks the order separately from git.** Branch position in the\r\n  stack is stored in local tracking state. Rebasing doesn't reorder the stack; it\r\n  only changes which commit each branch hangs off.\r\n\r\n`gh stack view` status icons:\r\n\r\n| Icon | Meaning      |\r\n| ---- | ------------ |\r\n| `✓`  | PR merged    |\r\n| `◎`  | PR queued    |\r\n| `○`  | PR open      |\r\n| `⚠`  | Needs rebase |\r\n\r\n---\r\n\r\n## Scenario: starting a new stack\r\n\r\n```bash\r\ngh stack init --base development my-first-branch\r\n```\r\n\r\nMultiple layers at once, bottom to top:\r\n\r\n```bash\r\ngh stack init --base development auth-layer api-routes ui-components\r\n```\r\n\r\nAdopting branches that already exist (also bottom to top):\r\n\r\n```bash\r\ngh stack init --base development dev/feat-a dev/feat-b dev/feat-c\r\n```\r\n\r\nExisting branches are adopted; missing ones are created.\r\n\r\n---\r\n\r\n## Scenario: adding a branch on top\r\n\r\n```bash\r\ngh stack top                       # jump to the furthest branch from trunk\r\ngh stack add dev/my-next-thing\r\n```\r\n\r\nUse `gh stack top`, not `gh stack up`. `up` moves exactly one layer (`gh stack up 3`\r\nfor three) and requires you to know the count; `top` gets there in one step.\r\n\r\nCreate the branch _and_ commit staged work in one go:\r\n\r\n```bash\r\ngh stack add -Am \"Add the thing\" dev/my-next-thing\r\n```\r\n\r\n- `-A` stages everything including untracked files\r\n- `-m` sets the commit message; omit the branch name and it's auto-generated from\r\n  the message\r\n\r\nEither way you end up checked out on the new branch, stacked on everything below.\r\n\r\n---\r\n\r\n## Scenario: committing to a branch in the middle\r\n\r\nThis is the common one. Say you're on `dev/task-5678-ui-fixes-jan-02`\r\nand it has two branches above it.\r\n\r\n```bash\r\ngit add -A && git commit -m \"your message\"\r\ngh stack rebase --upstack\r\n```\r\n\r\n`--upstack` replants only the branches above you. Nothing below changed, so there's\r\nno reason to touch it.\r\n\r\nNotes:\r\n\r\n- **Commit first.** Rebase requires a clean working tree.\r\n- If `gh stack view` shows no `⚠` after committing, you can skip the rebase.\r\n- Use plain `gh stack rebase` (no flag) when `development` has moved and you want\r\n  the new trunk pulled through the whole chain.\r\n\r\n---\r\n\r\n## Scenario: a branch shows `⚠ Needs rebase`\r\n\r\n```bash\r\ngh stack rebase\r\ngh stack view        # confirm the ⚠ is gone\r\n```\r\n\r\n### What this actually does\r\n\r\n`⚠` means the branch is attached to the right parent branch but not to its _tip_ —\r\nusually because you committed to a lower branch afterwards. Example from this repo:\r\n\r\n```\r\nbefore:  ...e977eadf (profile header update)  ←  4d26b902  build config\r\n                    └─ 911decc8 ─ 9ce33918  (two newer commits on task-5678)\r\n\r\nafter:   ...9ce33918 (profile detail page updates)  ←  1a0b64ac  build config\r\n```\r\n\r\nThe rebase took the build config commit's _change_, replayed it on top of the current tip,\r\nand wrote a **new commit**: `4d26b902` → `1a0b64ac`. Same message, same diff, new\r\nSHA. The branch never moved position in the stack — a rebase rewrites commits onto\r\na new base, it does not reorder anything.\r\n\r\nThis is why pushing after a rebase needs `--force-with-lease`, which `gh stack push`\r\nand `gh stack submit` handle for you.\r\n\r\n### Rebase variants\r\n\r\n| Command                       | Scope                                   |\r\n| ----------------------------- | --------------------------------------- |\r\n| `gh stack rebase`             | Whole stack, including a fetch of trunk |\r\n| `gh stack rebase --upstack`   | Current branch → top                    |\r\n| `gh stack rebase --downstack` | Trunk → current branch                  |\r\n| `gh stack rebase --no-trunk`  | Inter-branch only; skip fetching trunk  |\r\n\r\n### If it conflicts\r\n\r\n```bash\r\n# resolve the files, then:\r\ngit add <files>\r\ngh stack rebase --continue\r\n\r\n# or bail out — all branches are restored:\r\ngh stack rebase --abort\r\n```\r\n\r\n---\r\n\r\n## Scenario: publishing the stack as PRs\r\n\r\n```bash\r\ngh stack submit\r\n```\r\n\r\nOpens a single-screen editor: every branch without a PR is included by default,\r\ndeselect with the checkbox or `^x`, write each title/description, toggle draft\r\nstate with \"CREATE AS\", then `Ctrl+S` to submit them all.\r\n\r\nIt pushes all branches, creates the new PRs, fixes the base branch on existing PRs,\r\nand creates/updates the stack object on GitHub.\r\n\r\n```bash\r\ngh stack submit --auto     # skip the editor, auto-generated titles (creates drafts)\r\ngh stack submit --open     # mark new and existing PRs ready for review\r\n```\r\n\r\nIn a non-interactive terminal `--auto` is implied.\r\n\r\n### Push without touching PRs\r\n\r\n```bash\r\ngh stack push\r\n```\r\n\r\nPer-branch `--force-with-lease`, **not atomic** — one branch can land while another\r\nis rejected. Fix the rejected one and re-run; already-pushed branches stay as they\r\nare.\r\n\r\n---\r\n\r\n## Scenario: keeping in sync with the remote\r\n\r\n```bash\r\ngh stack sync\r\n```\r\n\r\nThe full round trip: fetch → reconcile local stack against GitHub → fast-forward\r\ntrunk → cascade-rebase onto updated parents → push all branches atomically\r\n(`--force-with-lease --atomic`) → sync PR state → link the PRs into a stack when\r\ntwo or more exist.\r\n\r\nIf PRs were added to the stack on GitHub, their branches are pulled down and\r\nappended locally. A clean \"remote is ahead\" update happens silently. A genuine\r\ndivergence prompts you to pick a source of truth; cancelling — or hitting a\r\ndivergence in a non-interactive terminal — aborts without pushing anything.\r\n\r\n---\r\n\r\n## Scenario: restructuring the stack\r\n\r\n```bash\r\ngh stack modify\r\n```\r\n\r\nInteractive TUI for dropping, folding, inserting, reordering, and renaming\r\nbranches. Changes are staged and applied together on `Ctrl+S`.\r\n\r\n```bash\r\ngh stack modify --continue    # after resolving conflicts\r\ngh stack modify --abort       # restore the pre-modify state\r\n```\r\n\r\nRun `gh stack submit` afterward if the affected branches have PRs.\r\n\r\n---\r\n\r\n## Scenario: merging\r\n\r\n```bash\r\ngh stack merge\r\n```\r\n\r\nGitHub's atomic stack merge: everything up to and including your chosen PR merges\r\nin one all-or-nothing operation — if any PR can't merge, none do. Interactive\r\nwizard lets you pick how far up to merge and the merge method.\r\n\r\n```bash\r\ngh stack merge 123     # merge up to a specific stack or PR number\r\ngh stack merge --yes   # no prompting\r\n```\r\n\r\nOnly basic PR state (open, not draft) is checked locally; branch protection and\r\nrepo rules are evaluated by GitHub at merge time. Bypassing merge requirements is\r\nnot supported for stacks. If the base branch uses a merge queue, the stack is\r\nqueued rather than merged directly.\r\n\r\n---\r\n\r\n## Scenario: picking up someone else's stack, or one of your own\r\n\r\n```bash\r\ngh stack checkout                 # interactive picker over all stacks, local + GitHub\r\ngh stack checkout 4               # stack number, then PR number, then branch name\r\ngh stack checkout 1234            # PR number\r\ngh stack checkout https://github.com/acme-co/product-web/pull/1234\r\ngh stack checkout dev/env-setup-updates\r\n```\r\n\r\nFor a PR not tracked locally, it queries the API, discovers the stack, fetches the\r\nbranches, and sets up local tracking.\r\n\r\n---\r\n\r\n## Navigation\r\n\r\n| Command             | Goes to                                     |\r\n| ------------------- | ------------------------------------------- |\r\n| `gh stack up [n]`   | `n` branches further from trunk (default 1) |\r\n| `gh stack down [n]` | `n` branches closer to trunk (default 1)    |\r\n| `gh stack top`      | Furthest branch from trunk                  |\r\n| `gh stack bottom`   | Closest branch to trunk                     |\r\n| `gh stack trunk`    | The trunk branch itself                     |\r\n| `gh stack switch`   | Interactive picker within the stack         |\r\n\r\nMerged branches are skipped automatically by `up` and `down`.\r\n\r\n---\r\n\r\n## Inspecting\r\n\r\n```bash\r\ngh stack view            # the default tree view\r\ngh stack view --short    # one line per branch\r\ngh stack view --json     # machine-readable\r\n```\r\n\r\nUseful sanity check that each layer really sits on top of its parent:\r\n\r\n```bash\r\ngit merge-base --is-ancestor <lower-branch> <upper-branch> && echo OK || echo STALE\r\n```\r\n\r\n---\r\n\r\n## Tearing down\r\n\r\n```bash\r\ngh stack unstack            # unstack the active stack on GitHub + drop local tracking\r\ngh stack unstack 4          # by stack number, from anywhere in the repo\r\ngh stack unstack --local    # drop local tracking only, leave GitHub alone\r\n```\r\n\r\nGitHub decides what can be unstacked — PRs that are queued or have auto-merge\r\nenabled stay stacked, and when any remain the stack is kept.\r\n\r\n---\r\n\r\n## Cheat sheet\r\n\r\n| I want to…                                | Command                                         |\r\n| ----------------------------------------- | ----------------------------------------------- |\r\n| See where I am                            | `gh stack view`                                 |\r\n| Start a stack                             | `gh stack init --base development <branch>`     |\r\n| Add a branch on top                       | `gh stack top && gh stack add <branch>`         |\r\n| Commit mid-stack and fix what's above     | `git commit …` then `gh stack rebase --upstack` |\r\n| Clear a `⚠`                               | `gh stack rebase`                               |\r\n| Open/update the PRs                       | `gh stack submit`                               |\r\n| Pull in trunk changes and push everything | `gh stack sync`                                 |\r\n| Reorder / drop / rename branches          | `gh stack modify`                               |\r\n| Merge the chain                           | `gh stack merge`                                |\r\n\r\n---\r\n\r\n## Gotchas\r\n\r\n- **Trunk is `development`.** Pass `--base development` on `init`.\r\n- **Rebasing rewrites SHAs.** Expect force-pushes; let `gh stack push` / `submit`\r\n  do them with `--force-with-lease` rather than running `git push -f` yourself.\r\n- **Local ≠ published.** `gh stack rebase` never touches the remote. Until you run\r\n  `submit` or `sync`, the stack exists only on your machine.\r\n- **Clean tree before rebasing.** Commit or stash first.\r\n- **`gh stack push` is not atomic**; `gh stack sync` is. Prefer `sync` when several\r\n  branches moved.\r\n- **Run `yarn precommit`** after a rebase pulls code across branches — the branch\r\n  you're on may now contain commits it hasn't been type-checked against."
      }
    },
    {
      "uri": "at://did:plc:cqqdxajzet74xn2ktz5y56rc/dev.atuan.post/3mv5ia4z7mp2l",
      "cid": "bafyreicgfugdprfu3pssf3bmmyubsk7cuvu5qmoua5pgba7szzglhc7g6i",
      "value": {
        "$type": "dev.atuan.post",
        "title": "Lua Type Annotations — A Quick, Clean Guide",
        "rawText": "Lua has no static types. These annotations are special comments read by the\nLua Language Server (LuaLS) — the thing powering your LSP completions and hover.\nThey do nothing at runtime. Think of them as .d.ts files, except they live inline\nas comments starting with ---@.\nRule of thumb: three dashes + @keyword. Two dashes (--) is a normal comment and\nis ignored by the LSP.\n1. The building blocks\n---@param — annotate a function argument\n`lua\n---@param name string\n---@param age number\nlocal function greet(name, age)\n  print(name, age)\nend\n`\n---@return — annotate what comes back\n`lua\n---@param a number\n---@param b number\n---@return number sum      -- the name sum is optional but shows up in hover\nlocal function add(a, b)\n  return a + b\nend\n`\nMultiple returns — one ---@return line each, in order:\n`lua\n---@return number x\n---@return number y\nlocal function get_pos() return 10, 20 end\n`\n---@type — annotate a variable\nUse this when the LSP can't infer the type on its own (e.g. a require, or a value\nthat starts as nil).\n`lua\n---@type string\nlocal title = getsomethingdynamic()\n---@type Window          -- a class name from your wezterm stubs\nlocal win = ...\n`\n2. The basic types\n| Annotation | Meaning                                  |\n| ---------- | ---------------------------------------- |\n| string   | text                                     |\n| number   | any number (Lua doesn't split int/float) |\n| integer  | whole number (LuaLS distinguishes this)  |\n| boolean  | true/false                               |\n| nil      | the absence of a value                   |\n| table    | generic table (avoid — be specific)      |\n| any      | opt out of checking                      |\n| function | any function                             |\n| MyClass  | a class you (or a stub) declared         |\nCombining types\n`lua\n---@param x string|number        -- union: string OR number\n---@param cb fun(n: number): boolean   -- a function type\n---@param opts? table            -- the ? means optional / may be nil\n---@return string?               -- may return a string or nil\n`\nA|B — union.\nT? — shorthand for T|nil (optional).\nfun(param: T): R — a function value with typed params and return.\nT[] — an array/list of T (e.g. string[]).\ntable — a map, e.g. table.\n`lua\n---@param names string[]                  -- list of strings\n---@param counts table   -- map name -> count\n`\n3. Describing table shapes with ---@class and ---@field\nThis is the big one. When you have a table with a known shape (like a config object),\ndeclare it once as a class:\n`lua\n---@class ResizeState\n---@field last_width  integer\n---@field last_height integer\n---@field dirty       boolean\n---@type ResizeState\nlocal state = { lastwidth = 0, lastheight = 0, dirty = false }\n`\nNow state. gives you completion for the three fields, and typos get flagged.\nOptional fields use ?:\n`lua\n---@class FontOpts\n---@field size number\n---@field family? string     -- optional\n`\n4. Aliases and enums with ---@alias\nGive a name to a union so you don't repeat it:\n`lua\n---@alias Corner \"top-left\"|\"top-right\"|\"bottom-left\"|\"bottom-right\"\n---@param where Corner\nlocal function anchor(where) end\n`\nNow anchor(\" will autocomplete the four string literals.\n5. The module pattern — local M = {}\nYou're basically right, and your TypeScript intuition transfers well.\n{} is a table (Lua's one-and-only data structure — it's object, array, dict, and\nnamespace all at once). The pattern is Lua's way of doing a module / an exported object:\n`lua\nlocal M = {}          -- like:  const M = {}   (an object we'll attach exports to)\n---@param name string\nfunction M.greet(name)         -- attach a function as a field of M\n  print(\"hi \" .. name)\nend\nfunction M.bye()\n  print(\"bye\")\nend\nreturn M               -- like:  export default M   /   module.exports = M\n`\nThen in another file:\n`lua\nlocal mymod = require(\"mymod\")   -- like:  import mymod from \"./mymod\"\nmymod.greet(\"doruk\")\n`\nSo:\nlocal M = {} — create an empty table to hold the module's public surface.\nfunction M.foo() — a field holding a function. Dot access, like obj.method in TS.\nreturn M — hand that table to whoever requires the file.\n. vs : — the one gotcha that isn't in TS\nM.foo(a) — plain function call. foo gets a as its first arg.\nM:foo(a) — method call. Syntactic sugar that secretly passes M as a hidden\n  first argument named self. These two are identical:\n`lua\nfunction M:foo(a)  end     -- 'self' is implicit\nfunction M.foo(self, a) end -- the same thing, written out\n`\nUse : when the function needs the instance (self); use . for plain\nnamespaced functions. Most simple modules just use . everywhere and never touch self.\nAnnotating a module cleanly\nYou can type the module table itself:\n`lua\n---@class MyMod\nlocal M = {}\n---@param name string\n---@return string\nfunction M.greet(name)\n  return \"hi \" .. name\nend\nreturn M\n`\n6. A realistic wezterm-flavored example\nPutting the patterns together the way you'd actually use them:\n`lua\nlocal wezterm = require(\"wezterm\")   ---@type Wezterm\n---@class SizingModule\nlocal M = {}\n---@class Dimensions\n---@field pixel_width  integer\n---@field pixel_height integer\n---@field dpi          number\n--- Compute leftover vertical pixels that don't fill a full cell.\n---@param window Window          -- type from your wezterm stubs\n---@param cell_height integer\n---@return integer remainder\nfunction M.edgegap(window, cellheight)\n  ---@type Dimensions\n  local dims = window:get_dimensions()\n  return dims.pixelheight % cellheight\nend\nwezterm.on(\"window-resized\", function(window, pane)\n  local gap = M.edge_gap(window, 20)\n  wezterm.log_info(\"edge gap:\", gap)\nend)\nreturn M\n`\n7. Quick reference / cheat sheet\n`lua\n---@param x T                 -- typed argument\n---@param x? T                -- optional argument (T|nil)\n---@return T name             -- typed return (name optional)\n---@type T                    -- type of the next variable\n---@class Name                -- declare a table shape\n---@field key T               --   ...its fields\n---@field key? T              --   ...optional field\n---@alias Name A|B|C          -- name a union / enum\n---@generic T                 -- generics (advanced)\n---@overload fun(...)         -- extra call signature (advanced)\n`\nTypes: string number integer boolean nil table function any\nComposites: T? A|B T[] table fun(a: T): R\n8. Getting the LSP to actually see your stubs\nAnnotations only help if LuaLS can resolve the class names (Window, Wezterm, …).\nPoint it at the definition files via .luarc.json in your config root:\n`json\n{\n  \"workspace\": {\n    \"library\": [\"/path/to/wezterm-types\"]\n  }\n}\n`\nIf hover already works on wezterm.on callbacks, you're set — reuse those same class\nnames in your own ---@param lines.\nTL;DR\n---@ comments = types for the LSP, zero runtime effect.\n---@param / ---@return / ---@type are 90% of daily use.\n---@class + ---@field describe table shapes (your config objects).\nlocal M = {} … return M is the module pattern — a table as a namespace, exactly\n  like an exported object in TS. Use . for plain functions, : when you need self.\n`lua\n---@param value number\n---@param in_min number\n---@param in_max number\n---@param out_min number\n---@param out_max number\n---@return number interpolation Maps a number from one range to another using linear interpolation\nlocal function linearmap(value, inmin, inmax, outmin, out_max)\n  return (value * (outmin - outmax)) / (inmax - inmin) +\n      (-inmin * (outmin - outmax)) / (inmin - in_max) +\n      out_max;\nend\n---@param lifecycle_event string\n---@param window Window\n---@param pane Pane\nlocal function fixfontsize(lifecycle_event, window, pane)\n  local overrides    = window:getconfigoverrides() or {}\n  local wd          = window:getdimensions()\n  local pd          = pane:getdimensions()\n  local A            = pd.pixelheight -- the frame we fill\n  local currentfont = overrides.fontsize or FONT_SIZE\n  -- rows that fit, counted IN THE SAME FRAME (A), from current cell size:\n  local curcell     = currentfont * w_d.dpi / 72 -- bootstrap estimate\n  local rows         = math.floor(A / cur_cell)\n  if rows  ', window, pane)\nend)\n-- Fires after the config file is reloaded\nwezterm.on('window-config-reloaded', function(window, pane)\n  fixfontsize('window-config-reloaded => ', window, pane)\nend)\n`",
        "createdAt": "2026-09-10T07:02:14.181Z",
        "rawMarkdown": "Lua has **no static types**. These annotations are special comments read by the\r\n**Lua Language Server (LuaLS)** — the thing powering your LSP completions and hover.\r\nThey do **nothing at runtime**. Think of them as `.d.ts` files, except they live inline\r\nas comments starting with `---@`.\r\n\r\n> Rule of thumb: three dashes + `@keyword`. Two dashes (`--`) is a normal comment and\r\n> is ignored by the LSP.\r\n\r\n---\r\n\r\n## 1. The building blocks\r\n\r\n### `---@param` — annotate a function argument\r\n\r\n```lua\r\n---@param name string\r\n---@param age number\r\nlocal function greet(name, age)\r\n  print(name, age)\r\nend\r\n```\r\n\r\n### `---@return` — annotate what comes back\r\n\r\n```lua\r\n---@param a number\r\n---@param b number\r\n---@return number sum      -- the name `sum` is optional but shows up in hover\r\nlocal function add(a, b)\r\n  return a + b\r\nend\r\n```\r\n\r\nMultiple returns — one `---@return` line each, in order:\r\n\r\n```lua\r\n---@return number x\r\n---@return number y\r\nlocal function get_pos() return 10, 20 end\r\n```\r\n\r\n### `---@type` — annotate a variable\r\n\r\nUse this when the LSP can't infer the type on its own (e.g. a `require`, or a value\r\nthat starts as `nil`).\r\n\r\n```lua\r\n---@type string\r\nlocal title = get_something_dynamic()\r\n\r\n---@type Window          -- a class name from your wezterm stubs\r\nlocal win = ...\r\n```\r\n\r\n---\r\n\r\n## 2. The basic types\r\n\r\n| Annotation | Meaning                                  |\r\n| ---------- | ---------------------------------------- |\r\n| `string`   | text                                     |\r\n| `number`   | any number (Lua doesn't split int/float) |\r\n| `integer`  | whole number (LuaLS distinguishes this)  |\r\n| `boolean`  | true/false                               |\r\n| `nil`      | the absence of a value                   |\r\n| `table`    | generic table (avoid — be specific)      |\r\n| `any`      | opt out of checking                      |\r\n| `function` | any function                             |\r\n| `MyClass`  | a class you (or a stub) declared         |\r\n\r\n### Combining types\r\n\r\n```lua\r\n---@param x string|number        -- union: string OR number\r\n---@param cb fun(n: number): boolean   -- a function type\r\n---@param opts? table            -- the `?` means optional / may be nil\r\n---@return string?               -- may return a string or nil\r\n```\r\n\r\n- `A|B` — union.\r\n- `T?` — shorthand for `T|nil` (optional).\r\n- `fun(param: T): R` — a function value with typed params and return.\r\n- `T[]` — an array/list of T (e.g. `string[]`).\r\n- `table<K, V>` — a map, e.g. `table<string, number>`.\r\n\r\n```lua\r\n---@param names string[]                  -- list of strings\r\n---@param counts table<string, integer>   -- map name -> count\r\n```\r\n\r\n---\r\n\r\n## 3. Describing table shapes with `---@class` and `---@field`\r\n\r\nThis is the big one. When you have a table with a known shape (like a config object),\r\ndeclare it once as a class:\r\n\r\n```lua\r\n---@class ResizeState\r\n---@field last_width  integer\r\n---@field last_height integer\r\n---@field dirty       boolean\r\n\r\n---@type ResizeState\r\nlocal state = { last_width = 0, last_height = 0, dirty = false }\r\n```\r\n\r\nNow `state.` gives you completion for the three fields, and typos get flagged.\r\n\r\nOptional fields use `?`:\r\n\r\n```lua\r\n---@class FontOpts\r\n---@field size number\r\n---@field family? string     -- optional\r\n```\r\n\r\n---\r\n\r\n## 4. Aliases and enums with `---@alias`\r\n\r\nGive a name to a union so you don't repeat it:\r\n\r\n```lua\r\n---@alias Corner \"top-left\"|\"top-right\"|\"bottom-left\"|\"bottom-right\"\r\n\r\n---@param where Corner\r\nlocal function anchor(where) end\r\n```\r\n\r\nNow `anchor(\"` will autocomplete the four string literals.\r\n\r\n---\r\n\r\n## 5. The module pattern — `local M = {}`\r\n\r\nYou're basically right, and your TypeScript intuition transfers well.\r\n\r\n`{}` **is** a table (Lua's one-and-only data structure — it's object, array, dict, and\r\nnamespace all at once). The pattern is Lua's way of doing a module / an exported object:\r\n\r\n```lua\r\nlocal M = {}          -- like:  const M = {}   (an object we'll attach exports to)\r\n\r\n---@param name string\r\nfunction M.greet(name)         -- attach a function as a field of M\r\n  print(\"hi \" .. name)\r\nend\r\n\r\nfunction M.bye()\r\n  print(\"bye\")\r\nend\r\n\r\nreturn M               -- like:  export default M   /   module.exports = M\r\n```\r\n\r\nThen in another file:\r\n\r\n```lua\r\nlocal mymod = require(\"mymod\")   -- like:  import mymod from \"./mymod\"\r\nmymod.greet(\"doruk\")\r\n```\r\n\r\nSo:\r\n\r\n- `local M = {}` — create an empty table to hold the module's public surface.\r\n- `function M.foo()` — a _field_ holding a function. Dot access, like `obj.method` in TS.\r\n- `return M` — hand that table to whoever `require`s the file.\r\n\r\n### `.` vs `:` — the one gotcha that isn't in TS\r\n\r\n- `M.foo(a)` — plain function call. `foo` gets `a` as its first arg.\r\n- `M:foo(a)` — **method** call. Syntactic sugar that secretly passes `M` as a hidden\r\n  first argument named `self`. These two are identical:\r\n\r\n```lua\r\nfunction M:foo(a)  end     -- 'self' is implicit\r\nfunction M.foo(self, a) end -- the same thing, written out\r\n```\r\n\r\nUse `:` when the function needs the instance (`self`); use `.` for plain\r\nnamespaced functions. Most simple modules just use `.` everywhere and never touch `self`.\r\n\r\n### Annotating a module cleanly\r\n\r\nYou can type the module table itself:\r\n\r\n```lua\r\n---@class MyMod\r\nlocal M = {}\r\n\r\n---@param name string\r\n---@return string\r\nfunction M.greet(name)\r\n  return \"hi \" .. name\r\nend\r\n\r\nreturn M\r\n```\r\n\r\n---\r\n\r\n## 6. A realistic wezterm-flavored example\r\n\r\nPutting the patterns together the way you'd actually use them:\r\n\r\n```lua\r\nlocal wezterm = require(\"wezterm\")   ---@type Wezterm\r\n\r\n---@class SizingModule\r\nlocal M = {}\r\n\r\n---@class Dimensions\r\n---@field pixel_width  integer\r\n---@field pixel_height integer\r\n---@field dpi          number\r\n\r\n--- Compute leftover vertical pixels that don't fill a full cell.\r\n---@param window Window          -- type from your wezterm stubs\r\n---@param cell_height integer\r\n---@return integer remainder\r\nfunction M.edge_gap(window, cell_height)\r\n  ---@type Dimensions\r\n  local dims = window:get_dimensions()\r\n  return dims.pixel_height % cell_height\r\nend\r\n\r\nwezterm.on(\"window-resized\", function(window, pane)\r\n  local gap = M.edge_gap(window, 20)\r\n  wezterm.log_info(\"edge gap:\", gap)\r\nend)\r\n\r\nreturn M\r\n```\r\n\r\n---\r\n\r\n## 7. Quick reference / cheat sheet\r\n\r\n```lua\r\n---@param x T                 -- typed argument\r\n---@param x? T                -- optional argument (T|nil)\r\n---@return T name             -- typed return (name optional)\r\n---@type T                    -- type of the next variable\r\n---@class Name                -- declare a table shape\r\n---@field key T               --   ...its fields\r\n---@field key? T              --   ...optional field\r\n---@alias Name A|B|C          -- name a union / enum\r\n---@generic T                 -- generics (advanced)\r\n---@overload fun(...)         -- extra call signature (advanced)\r\n```\r\n\r\nTypes: `string number integer boolean nil table function any`\r\nComposites: `T?` `A|B` `T[]` `table<K,V>` `fun(a: T): R`\r\n\r\n---\r\n\r\n## 8. Getting the LSP to actually see your stubs\r\n\r\nAnnotations only help if LuaLS can resolve the class names (`Window`, `Wezterm`, …).\r\nPoint it at the definition files via `.luarc.json` in your config root:\r\n\r\n```json\r\n{\r\n  \"workspace\": {\r\n    \"library\": [\"/path/to/wezterm-types\"]\r\n  }\r\n}\r\n```\r\n\r\nIf hover already works on `wezterm.on` callbacks, you're set — reuse those same class\r\nnames in your own `---@param` lines.\r\n\r\n---\r\n\r\n### TL;DR\r\n\r\n- `---@` comments = types for the LSP, zero runtime effect.\r\n- `---@param` / `---@return` / `---@type` are 90% of daily use.\r\n- `---@class` + `---@field` describe table shapes (your config objects).\r\n- `local M = {}` … `return M` is the module pattern — a table as a namespace, exactly\r\n  like an exported object in TS. Use `.` for plain functions, `:` when you need `self`.\r\n\r\n```lua\r\n---@param value number\r\n---@param in_min number\r\n---@param in_max number\r\n---@param out_min number\r\n---@param out_max number\r\n---@return number interpolation Maps a number from one range to another using linear interpolation\r\nlocal function linear_map(value, in_min, in_max, out_min, out_max)\r\n  return (value * (out_min - out_max)) / (in_max - in_min) +\r\n      (-in_min * (out_min - out_max)) / (in_min - in_max) +\r\n      out_max;\r\nend\r\n\r\n---@param lifecycle_event string\r\n---@param window Window\r\n---@param pane Pane\r\nlocal function fix_font_size(lifecycle_event, window, pane)\r\n  local overrides    = window:get_config_overrides() or {}\r\n  local w_d          = window:get_dimensions()\r\n  local p_d          = pane:get_dimensions()\r\n  local A            = p_d.pixel_height -- the frame we fill\r\n  local current_font = overrides.font_size or FONT_SIZE\r\n\r\n  -- rows that fit, counted IN THE SAME FRAME (A), from current cell size:\r\n  local cur_cell     = current_font * w_d.dpi / 72 -- bootstrap estimate\r\n  local rows         = math.floor(A / cur_cell)\r\n  if rows < 1 then return end\r\n\r\n  local new_cell = A / rows -- fills A exactly\r\n  local new_font = new_cell * 72 / w_d.dpi\r\n\r\n  -- CONVERGENCE GUARD — this is what stops the reload loop:\r\n  if math.abs(new_font - current_font) < 0.25 then\r\n    return\r\n  end\r\n\r\n  wezterm.log_info(lifecycle_event, 'rows', rows, 'new_font', new_font)\r\n  overrides.font_size = new_font\r\n  window:set_config_overrides(overrides)\r\nend\r\n\r\nwezterm.on('window-resized', function(window, pane)\r\n  fix_font_size('window-resize => ', window, pane)\r\nend)\r\n\r\n-- Fires after the config file is reloaded\r\nwezterm.on('window-config-reloaded', function(window, pane)\r\n  fix_font_size('window-config-reloaded => ', window, pane)\r\nend)\r\n```"
      }
    }
  ]
}