aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 11:07:02 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 11:07:02 +0200
commit23f666e92ce0ea6b40f241a33edee43160ee0d81 (patch)
treee3e631ff4aecfe708aeb5bb796b6f25011561a35 /docs
parent807d454d53f377a6bed753a50c1a5952524db429 (diff)
downloadquickshell-23f666e92ce0ea6b40f241a33edee43160ee0d81.tar.gz
quickshell-23f666e92ce0ea6b40f241a33edee43160ee0d81.zip
docs(git): implementation plan for the git config page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-10-16-git-config.md941
1 files changed, 941 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-16-git-config.md b/docs/superpowers/plans/2026-10-16-git-config.md
new file mode 100644
index 0000000..bcdd58d
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-16-git-config.md
@@ -0,0 +1,941 @@
+# Global Git Config Page Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A drawer page, reached from the user page, that lists every global git key and edits, adds and removes values through `git config`.
+
+**Architecture:** `desktop/modules/git/gitcfg` (bash) is the only thing touching git; `Git.js` parses its `list` output; `GitPage.qml` renders and calls it, the way `UserPage.qml` calls `userctl`. The drawer gains a `go(name)` page signal and a module `up` property so the page opens from the user page and returns there.
+
+**Tech Stack:** bash, git 2.55 (`--fixed-value`), Quickshell 0.3.1 QML, node for the JS test.
+
+Spec: `docs/superpowers/specs/2026-10-16-git-config-design.md`.
+
+**Measured while planning, which the code depends on:**
+- `git config --fixed-value --replace-all -- key new old` with no value equal to `old` **appends** `new` and exits 0. So `gitcfg set` checks the old value exists first (`--fixed-value --get-all -- key old`, exit 1 when absent).
+- `--unset` of a missing value exits 5 with no message.
+- A bad key exits 2 with `error: key does not contain a section: <key>`.
+- A missing config file makes `--list` exit 128 with `fatal: unable to read config file`.
+- `-z --list` prints `name\nvalue\0`, and a valueless boolean (`[x]` then `flag`) prints `x.flag\0` with no newline.
+- `` (nf-dev-git) is in Inconsolata Nerd Font (`fc-list ":charset=e702" family`).
+
+**Rules from AGENTS.md that apply:** GPLv2 header on every new file; `pkill -x qs` never `-f`; never run `reload.sh` from a tool call; a new component *file* needs a change to a file that imports its directory (`desktop/shell.qml` here, which this plan edits anyway); a component property must not share the enclosing object's id (`owner: gm`, not `gm: gm`); a `Row` sizes to children, so rows use anchors in an `Item`.
+
+---
+
+## File map
+
+- Create `desktop/modules/git/gitcfg`: list/set/add/unset over `git config --global`.
+- Create `desktop/modules/git/test-gitcfg.sh`: runs gitcfg against a temp `GIT_CONFIG_GLOBAL`.
+- Create `desktop/modules/git/Git.js`: `parse(text)`.
+- Create `desktop/modules/git/test_git.js`: node check for `parse`.
+- Create `desktop/modules/git/GitModule.qml`, `desktop/modules/git/GitPage.qml`.
+- Create `desktop/modules/git/README.md`.
+- Modify `desktop/Module.qml` (`up`), `desktop/Page.qml` (`go`), `desktop/Drawer.qml` (`back()`, page wiring).
+- Modify `desktop/shell.qml` (import, register `GitModule`).
+- Modify `desktop/modules/user/UserPage.qml` (button, signal), `desktop/modules/user/UserModule.qml` (wire it), `desktop/modules/user/README.md`.
+- Modify `desktop/README.md` (module list), `AGENTS.md` (replace-all gotcha), the spec (two corrections).
+
+---
+
+### Task 1: gitcfg and its check
+
+**Files:**
+- Create: `desktop/modules/git/test-gitcfg.sh`
+- Create: `desktop/modules/git/gitcfg`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/modules/git/test-gitcfg.sh`:
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The one runnable check for gitcfg. GIT_CONFIG_GLOBAL points git at a file
+# in a temp directory, so the real ~/.gitconfig is never read or written.
+#
+# Usage: ./test-gitcfg.sh (exit 0 = all passed)
+
+set -u
+
+here="$(cd "$(dirname "$0")" && pwd)"
+ctl="$here/gitcfg"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+export GIT_CONFIG_GLOBAL="$tmp/gitconfig"
+
+fail=0
+check() {
+ if [[ "$2" == "$3" ]]; then
+ echo "ok $1"
+ else
+ echo "FAIL $1"
+ printf ' want: %q\n got: %q\n' "$2" "$3"
+ fail=1
+ fi
+}
+vals() { git config --global --get-all "$1" | paste -sd,; }
+
+# Values starting with a dash, as versionsort.prereleasesuffix holds, and a
+# comment that every write must leave alone.
+printf '# keep me\n[v]\n\ts = -rc\n\ts = -beta\n\ts = -alpha\n' > "$GIT_CONFIG_GLOBAL"
+
+check "list" $'v.s\n-rc|v.s\n-beta|v.s\n-alpha|' "$("$ctl" list | tr '\0' '|')"
+
+out="$("$ctl" set v.s -beta -BETA)"; rc=$?
+check "set one of three: rc" 0 "$rc"
+check "set one of three: silent" "" "$out"
+check "set one of three: values" "-rc,-BETA,-alpha" "$(vals v.s)"
+
+# git would append here and exit 0; gitcfg must refuse instead.
+out="$("$ctl" set v.s -gone x)"; rc=$?
+check "set missing old: rc" 1 "$rc"
+check "set missing old: says so" 1 "$([[ -n "$out" ]] && echo 1)"
+check "set missing old: nothing appended" "-rc,-BETA,-alpha" "$(vals v.s)"
+
+out="$("$ctl" unset v.s -rc)"; rc=$?
+check "unset one of three: rc" 0 "$rc"
+check "unset one of three: values" "-BETA,-alpha" "$(vals v.s)"
+
+out="$("$ctl" unset v.s -gone)"; rc=$?
+check "unset missing: rc" 1 "$rc"
+check "unset missing: says so" 1 "$([[ -n "$out" ]] && echo 1)"
+
+out="$("$ctl" add a.b "x=y")"; rc=$?
+check "add with =: rc" 0 "$rc"
+check "add with =: value" "x=y" "$(vals a.b)"
+
+out="$("$ctl" add "bad key" v)"; rc=$?
+check "add bad key: rc" 1 "$rc"
+check "add bad key: git's error" 1 "$([[ "$out" == *section* ]] && echo 1)"
+
+check "comment kept" 1 "$(grep -c '^# keep me$' "$GIT_CONFIG_GLOBAL")"
+
+"$ctl" bogus >/dev/null 2>&1; rc=$?
+check "usage: rc" 2 "$rc"
+
+exit "$fail"
+```
+
+Then `chmod +x desktop/modules/git/test-gitcfg.sh`.
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `desktop/modules/git/test-gitcfg.sh`
+Expected: every check FAIL (gitcfg does not exist, `No such file or directory`), exit 1.
+
+- [ ] **Step 3: Write gitcfg**
+
+`desktop/modules/git/gitcfg`:
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The global git config, for the drawer's git page.
+#
+# gitcfg list git config --global -z --list
+# gitcfg set <key> <old> <new> replace the one value equal to <old>
+# gitcfg add <key> <value> add a value
+# gitcfg unset <key> <value> remove the one value equal to <value>
+#
+# Every write goes through git config, so the file keeps its comments, layout
+# and quoting. --fixed-value makes a value a literal rather than a regex, so a
+# write touches one value of a multi-valued key, and the `--` before the key
+# keeps a leading dash an argument rather than an option.
+#
+# A write prints nothing on success. On failure it prints the reason on
+# stdout, for the page, and exits 1.
+
+set -u
+
+usage() {
+ printf 'usage: %s list | set <key> <old> <new> | add <key> <value> | unset <key> <value>\n' \
+ "${0##*/}" >&2
+ exit 2
+}
+
+g() {
+ local err
+ err="$(git config --global "$@" 2>&1)" || { printf '%s\n' "${err:-git config failed}"; exit 1; }
+}
+
+# --replace-all with no value equal to <old> appends <new> and succeeds, and
+# --unset of a missing value fails silently, so both check first: the file
+# may have changed in the editor since the page read it.
+has() {
+ git config --global --fixed-value --get-all -- "$1" "$2" >/dev/null \
+ || { printf '%s no longer holds that value\n' "$1"; exit 1; }
+}
+
+case "${1:-}" in
+ list) [[ $# -eq 1 ]] || usage; exec git config --global -z --list ;;
+ set) [[ $# -eq 4 ]] || usage; has "$2" "$3"; g --fixed-value --replace-all -- "$2" "$4" "$3" ;;
+ add) [[ $# -eq 3 ]] || usage; g --add -- "$2" "$3" ;;
+ unset) [[ $# -eq 3 ]] || usage; has "$2" "$3"; g --fixed-value --unset -- "$2" "$3" ;;
+ *) usage ;;
+esac
+```
+
+Then `chmod +x desktop/modules/git/gitcfg`.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `desktop/modules/git/test-gitcfg.sh`
+Expected: every line `ok`, exit 0.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/git/gitcfg desktop/modules/git/test-gitcfg.sh
+git commit -F - <<'EOF'
+feat(git): gitcfg, global git config through git itself
+
+list, set, add and unset over git config --global. --fixed-value lets a
+write touch one value of a multi-valued key. set and unset check the old
+value is still there first: --replace-all with no match appends and exits
+0, and the file may have changed in an editor since the page read it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 2: Git.js parse
+
+**Files:**
+- Create: `desktop/modules/git/test_git.js`
+- Create: `desktop/modules/git/Git.js`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/modules/git/test_git.js`:
+
+```js
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Run: node desktop/modules/git/test_git.js
+// Git.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Git.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const G = new Function(src + "\nreturn { parse };")();
+
+// `gitcfg list` output: name, newline, value, NUL. A subsection's dots stay
+// in the section; a value keeps its = and its own newlines; a valueless
+// boolean has no newline at all.
+assert.deepStrictEqual(G.parse("user.name\nJane D.\0color.branch.remote\nyellow\0"
+ + "alias.x\n!f() { a=1\nb; }\0x.flag\0"), [
+ { name: "user.name", section: "user", key: "name", value: "Jane D." },
+ { name: "color.branch.remote", section: "color.branch", key: "remote", value: "yellow" },
+ { name: "alias.x", section: "alias", key: "x", value: "!f() { a=1\nb; }" },
+ { name: "x.flag", section: "x", key: "flag", value: "" },
+]);
+// No config file: gitcfg prints nothing.
+assert.deepStrictEqual(G.parse(""), []);
+
+console.log("test_git: ok");
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `node desktop/modules/git/test_git.js`
+Expected: FAIL, `ENOENT: no such file or directory` for `Git.js`.
+
+- [ ] **Step 3: Write Git.js**
+
+`desktop/modules/git/Git.js`:
+
+```js
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+.pragma library
+
+// Pure helpers for the git page, tested by test_git.js under node.
+
+// `gitcfg list` (git config -z --list) into [{name, section, key, value}],
+// in file order. The key is after the last dot, so a subsection stays in the
+// section: color.branch.remote is section color.branch.
+function parse(text) {
+ return text.split("\0").filter(e => e !== "").map(e => {
+ const nl = e.indexOf("\n");
+ const name = nl < 0 ? e : e.slice(0, nl);
+ const dot = name.lastIndexOf(".");
+ return { name, section: name.slice(0, dot), key: name.slice(dot + 1),
+ value: nl < 0 ? "" : e.slice(nl + 1) };
+ });
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `node desktop/modules/git/test_git.js`
+Expected: `test_git: ok`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/git/Git.js desktop/modules/git/test_git.js
+git commit -F - <<'EOF'
+feat(git): parse git config -z --list for the page
+
+NUL between entries and the first newline between name and value, so
+values holding = or newlines survive. The key is after the last dot, so
+color.branch.remote groups under color.branch.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 3: Drawer navigation, `go` and `up`
+
+**Files:**
+- Modify: `desktop/Module.qml` (after the `tiled` property)
+- Modify: `desktop/Page.qml` (after `signal dismiss`)
+- Modify: `desktop/Drawer.qml` (`back()` around line 74, the page loader's `onLoaded` around line 244)
+
+No automated check exists for QML navigation; Task 6 checks it by hand. Existing pages keep `up: ""`, so their behaviour is unchanged.
+
+- [ ] **Step 1: Module.qml, add `up`**
+
+After the `tiled` property block:
+
+```qml
+ // The page back() returns to from this module's page. Empty is the grid.
+ // The git page sets "user", the page it is opened from.
+ property string up: ""
+```
+
+- [ ] **Step 2: Page.qml, add `go`**
+
+After `signal dismiss`:
+
+```qml
+ // Opens another module's page by name, for a page that links to one (the
+ // user page opens the git page).
+ signal go(string name)
+```
+
+- [ ] **Step 3: Drawer.qml, `back()` follows `up`**
+
+Replace
+
+```qml
+ else if (root.page) root.page = "";
+```
+
+with
+
+```qml
+ else if (root.page) root.page = root.current?.up ?? "";
+```
+
+and update the comment above `back()` to:
+
+```qml
+ // One step out: history, then a page (to its module's `up`, the grid by
+ // default), then the drawer itself. Escape, the mouse's back button and
+ // a page's back arrow.
+```
+
+- [ ] **Step 4: Drawer.qml, page wiring**
+
+In the page loader, replace
+
+```qml
+ // The Page's back arrow returns to the grid. Without this
+ // the header button is inert, since the page only emits
+ // `back` and does not know who hosts it.
+ onLoaded: {
+ if (item && item.back) item.back.connect(() => root.page = "");
+ if (item && item.dismiss) item.dismiss.connect(root.close);
+ }
+```
+
+with
+
+```qml
+ // The Page's back arrow steps out as Escape does. Without
+ // this the header button is inert, since the page only
+ // emits `back` and does not know who hosts it.
+ onLoaded: {
+ if (item && item.back) item.back.connect(root.back);
+ if (item && item.dismiss) item.dismiss.connect(root.close);
+ if (item && item.go) item.go.connect(root.show);
+ }
+```
+
+`root.back()` checks `history` first, which is false while a module page is
+loaded, so the arrow still lands on `up` (or the grid).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/Module.qml desktop/Page.qml desktop/Drawer.qml
+git commit -F - <<'EOF'
+feat(desktop): pages can open other pages and step back to them
+
+Page gains go(name), wired to the drawer's show(). Module gains up, the
+page back() returns to, empty for the grid as before. The back arrow now
+calls back() instead of clearing the page itself, so the arrow, Escape
+and the mouse's back button agree. For the git page, opened from the
+user page.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 4: The git module and page
+
+**Files:**
+- Create: `desktop/modules/git/GitModule.qml`
+- Create: `desktop/modules/git/GitPage.qml`
+- Modify: `desktop/shell.qml` (import list, `modules:` list)
+
+- [ ] **Step 1: GitModule.qml**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// The global git config. No tile: the user page's Git section opens it, and
+// back returns there.
+Module {
+ id: gm
+ name: "git"
+ label: "Git config"
+ // nf-dev-git, present in Inconsolata Nerd Font.
+ icon: ""
+ tiled: false
+ up: "user"
+
+ page: Component {
+ Page {
+ title: "Git config"
+ GitPage { width: parent.width; owner: gm }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: GitPage.qml**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import "../.."
+import "Git.js" as G
+
+// Every global git value, one row each, grouped by section in file order.
+// Enter on a row saves it, Escape restores it, Remove drops that one value.
+// Every write goes through gitcfg and is followed by a fresh list, so a row
+// always shows what git holds. A failed write shows git's reason under the
+// row it came from.
+Column {
+ id: page
+
+ spacing: 6
+
+ readonly property string ctl: Qt.resolvedUrl("gitcfg").toString().replace(/^file:\/\//, "")
+ property var entries: []
+ // Keyed by tag(): name and value, which survive the reload after a failed
+ // write because the value did not change.
+ property var errors: ({})
+ property bool busy: false
+ // The module, which outlives the page: a write in flight finishes even if
+ // the page is closed. Not named `gm`, the module's id (it would bind to itself).
+ required property QtObject owner
+
+ function tag(e) { return e.name + "=" + e.value; }
+
+ // One Process per call, so reads and writes never share one (a running
+ // Process ignores a second start, AGENTS.md). cb gets stdout.
+ function run(cmd, cb) {
+ op.createObject(page.owner, { command: cmd, cb: cb }).running = true;
+ }
+ Component {
+ id: op
+ Process {
+ id: proc
+ property var cb: null
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try { proc.cb(text); }
+ finally { proc.destroy(); }
+ }
+ }
+ }
+ }
+
+ function load() {
+ page.run([page.ctl, "list"], out => {
+ page.entries = G.parse(out);
+ page.busy = false;
+ });
+ }
+
+ // errKey: where a failure is shown. ok: run on success only.
+ function write(args, errKey, ok) {
+ if (page.busy) return;
+ page.busy = true;
+ page.run([page.ctl].concat(args), out => {
+ const e = {};
+ if (out.trim() !== "") e[errKey] = out.trim();
+ else if (ok) ok();
+ page.errors = e;
+ page.load();
+ });
+ }
+
+ Component.onCompleted: page.load()
+
+ // Detached, so the editor outlives the drawer. ponytail: ~/.gitconfig is
+ // the one global file here; ~/.config/git/config would be missed.
+ Button {
+ text: "Edit in editor"
+ onClicked: Quickshell.execDetached(["sublime_text", Quickshell.env("HOME") + "/.gitconfig"])
+ }
+
+ Repeater {
+ model: page.entries
+
+ delegate: Column {
+ id: row
+ required property var modelData
+ required property int index
+ readonly property string tag: page.tag(modelData)
+ width: page.width
+ spacing: 4
+ enabled: !page.busy
+
+ // A section's heading, above its first row.
+ Text {
+ visible: row.index === 0 || page.entries[row.index - 1].section !== row.modelData.section
+ topPadding: 10
+ bottomPadding: 2
+ text: row.modelData.section
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+
+ // An Item with anchors, not a Row: a Row sizes to its children
+ // and fixed widths in one overflow the page (AGENTS.md).
+ Item {
+ width: parent.width
+ height: 34
+
+ Text {
+ id: keyLabel
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: parent.width * 0.32
+ elide: Text.ElideRight
+ text: row.modelData.key
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+
+ Rectangle {
+ anchors { left: keyLabel.right; leftMargin: 8; right: del.left; rightMargin: 8 }
+ height: parent.height
+ radius: 8
+ color: Theme.surface
+
+ // ponytail: one line; a value holding a newline (a long
+ // alias) shows joined and is best edited in the editor.
+ TextInput {
+ id: input
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ text: row.modelData.value
+ onAccepted: if (text !== row.modelData.value)
+ page.write(["set", row.modelData.name, row.modelData.value, text], row.tag)
+ // An edit in progress is undone first; an unedited
+ // field lets Escape through, to leave the page.
+ Keys.onEscapePressed: event => {
+ if (text !== row.modelData.value) text = row.modelData.value;
+ else event.accepted = false;
+ }
+ }
+ }
+
+ Button {
+ id: del
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Remove"
+ danger: true
+ onClicked: page.write(["unset", row.modelData.name, row.modelData.value], row.tag)
+ }
+ }
+
+ Text {
+ width: parent.width
+ visible: !!page.errors[row.tag]
+ text: page.errors[row.tag] ?? ""
+ wrapMode: Text.Wrap
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+ }
+
+ // Add: section.key and a value. The fields clear only when git took it.
+ Text {
+ topPadding: 10
+ bottomPadding: 2
+ text: "Add"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+
+ Item {
+ width: parent.width
+ height: 34
+ enabled: !page.busy
+
+ Rectangle {
+ id: newKeyBox
+ anchors { left: parent.left }
+ width: parent.width * 0.32
+ height: parent.height
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ id: newKey
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: "section.key"
+ color: Theme.subtext
+ font: parent.font
+ }
+ }
+ }
+
+ Rectangle {
+ anchors { left: newKeyBox.right; leftMargin: 8; right: addBtn.left; rightMargin: 8 }
+ height: parent.height
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ id: newValue
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ onAccepted: if (addBtn.enabled) addBtn.clicked()
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: "value"
+ color: Theme.subtext
+ font: parent.font
+ }
+ }
+ }
+
+ Button {
+ id: addBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Add"
+ enabled: newKey.text.trim() !== ""
+ onClicked: page.write(["add", newKey.text.trim(), newValue.text], "add",
+ () => { newKey.text = ""; newValue.text = ""; })
+ }
+ }
+
+ Text {
+ width: parent.width
+ visible: !!page.errors.add
+ text: page.errors.add ?? ""
+ wrapMode: Text.Wrap
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+}
+```
+
+- [ ] **Step 3: Register in shell.qml**
+
+Add `import "modules/git"` after `import "modules/clipboard"`, and add `GitModule {},` after `UserModule {},` in the `modules:` list. Editing `shell.qml` also forces the drawer to re-index its imports, which a new directory needs (AGENTS.md, Reloading).
+
+- [ ] **Step 4: Check the shell loads it**
+
+Run: `timeout 5 qs log -p desktop 2>&1 | tail -5`
+Expected: `Reloading configuration...` then `Configuration Loaded`, no `ReferenceError`, `TypeError` or `Ignoring unresolvable import` naming `git`. Then open the page without the user page link: `qs -p desktop ipc call drawer open git`, and check the log again for errors. Ask the user to look at it; do not screenshot.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/git/GitModule.qml desktop/modules/git/GitPage.qml desktop/shell.qml
+git commit -F - <<'EOF'
+feat(git): the global git config page
+
+Every global value as a row under its section, in file order: Enter saves,
+Escape undoes, Remove drops that one value, and an Add row takes
+section.key and a value. Each write goes through gitcfg and is followed by
+a fresh list, so a row shows what git holds; a failure shows git's reason
+under its row. "Edit in editor" opens ~/.gitconfig in Sublime Text.
+
+No tile: up is "user", so back returns to the user page it opens from.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 5: The link from the user page
+
+**Files:**
+- Modify: `desktop/modules/user/UserPage.qml` (signal near the other properties, button before Save at the end)
+- Modify: `desktop/modules/user/UserModule.qml` (page component)
+
+- [ ] **Step 1: UserPage.qml, signal**
+
+After `required property QtObject owner` and its comment, add:
+
+```qml
+ // The "More git settings" button. The page has no reach into the drawer;
+ // UserModule turns this into Page.go("git").
+ signal moreGit
+```
+
+- [ ] **Step 2: UserPage.qml, button**
+
+Directly before the final `Button { ... text: form.busy ? "Saving…" : "Save" ... }`, add:
+
+```qml
+ // Every other global git key, on its own page.
+ Button {
+ text: "More git settings"
+ onClicked: form.moreGit()
+ }
+```
+
+- [ ] **Step 3: UserModule.qml, wire it**
+
+Replace
+
+```qml
+ page: Component {
+ Page {
+ title: "User"
+ UserPage { width: parent.width; owner: um }
+ }
+ }
+```
+
+with
+
+```qml
+ page: Component {
+ Page {
+ id: pg
+ title: "User"
+ UserPage { width: parent.width; owner: um; onMoreGit: pg.go("git") }
+ }
+ }
+```
+
+- [ ] **Step 4: Check by hand**
+
+Run: `timeout 5 qs log -p desktop 2>&1 | tail -5`, expect `Configuration Loaded` and no errors. Ask the user to: open the drawer, click the greeting row, click "More git settings", check the list against `git config --global --list`, edit one value and press Enter, press Escape on an edited field, then press Escape again (expect the user page, not the grid), then the back arrow on the user page (expect the grid).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/user/UserPage.qml desktop/modules/user/UserModule.qml
+git commit -F - <<'EOF'
+feat(user): open the git config page from the Git section
+
+A "More git settings" button under the Git fields. The page emits
+moreGit and the module turns it into Page.go("git"), since the page
+itself has no reach into the drawer.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 6: Documentation
+
+**Files:**
+- Create: `desktop/modules/git/README.md`
+- Modify: `desktop/modules/user/README.md`, `desktop/README.md` (module list, line ~38), `AGENTS.md` (Per-component notes), `docs/superpowers/specs/2026-10-16-git-config-design.md`
+
+- [ ] **Step 1: desktop/modules/git/README.md**
+
+```markdown
+# git
+
+The global git config (`~/.gitconfig`), every key, reached from the user
+page's "More git settings" button. Back returns to the user page.
+
+One row per value, grouped by section in file order, so a key set several
+times shows several rows. Enter saves a row, Escape undoes an edit, Remove
+drops that one value, and the Add row takes `section.key` and a value.
+"Edit in editor" opens the file in Sublime Text.
+
+`gitcfg` does every read and write, through `git config`, so the file keeps
+its comments and layout. `--fixed-value` makes a write touch one value of a
+multi-valued key. `set` and `unset` first check the old value is still
+there, because `--replace-all` with no match appends instead of failing, and
+the file may have changed in the editor. After every write the page reads
+the list again; a failure shows git's reason under its row.
+
+Checks: `./test-gitcfg.sh` (a throwaway `GIT_CONFIG_GLOBAL`) and
+`node desktop/modules/git/test_git.js`.
+
+Not here: per-repository, system and `[include]`d configs. A value holding a
+newline edits on one line; use the editor for those.
+```
+
+- [ ] **Step 2: user README**
+
+In `desktop/modules/user/README.md`, replace
+
+```
+shows the name and email git will get, read-only, and the signing key.
+```
+
+with
+
+```
+shows the name and email git will get, read-only, and the signing key;
+"More git settings" opens the full global git config (`../git/`).
+```
+
+- [ ] **Step 3: desktop README**
+
+After the `modules/clipboard/` line in `desktop/README.md` add:
+
+```
+ modules/git/ the global git config, opened from the user page
+```
+
+- [ ] **Step 4: AGENTS.md**
+
+Append to the Per-component notes list, after the `busctl get-property` entry:
+
+```markdown
+- **`git config --replace-all` with an unmatched value appends.** With
+ `--fixed-value` and an old value no longer in the file, git adds the new
+ value and exits 0, so a stale edit duplicates a key instead of failing;
+ `--unset` of a missing value exits 5 with no message. `gitcfg` checks
+ with `--get-all` first.
+```
+
+- [ ] **Step 5: Spec corrections**
+
+In `docs/superpowers/specs/2026-10-16-git-config-design.md`, replace
+
+```
+ - git's stderr passes through and its exit status is the helper's.
+```
+
+with
+
+```
+ - A write prints nothing on success; on failure, git's message on stdout
+ (for the page) and exit 1. `set` and `unset` first check the old value
+ is still there: `--replace-all` with no match appends and exits 0.
+```
+
+- [ ] **Step 6: Run both checks once more**
+
+Run: `desktop/modules/git/test-gitcfg.sh && node desktop/modules/git/test_git.js && node desktop/modules/user/test_user.js`
+Expected: all `ok`, `test_git: ok`, `test_user: ok`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add desktop/modules/git/README.md desktop/modules/user/README.md desktop/README.md AGENTS.md docs/superpowers/specs/2026-10-16-git-config-design.md
+git commit -F - <<'EOF'
+docs(git): the git config page, and replace-all appending on no match
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```