From bb3908019583f7e556371203a8392e263a624d43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 12:19:30 +0000 Subject: [PATCH 1/4] docs: prepare v0.5.0 Git workflow release - Rewrite GIT_WORKFLOW.md as a user guide (draft to commit, validation, identity, safe push, remote changes, failed pushes, stack history, recovering an unpushed local commit) followed by the technical reference; publish it under "Start here". - Add "Commit and push" to Getting started; update README, overview, installation, data model, architecture, security, testing and Git provider docs to match what shipped. - Mark PR #4 / Git workflow complete and PR #5 / deployment routing as the next phase in the roadmap, AGENTS.md and the UI/UX plan. - Copy pass on Changes, editor, docs and History strings: plain-language failure reasons with technical detail second, accurate outdated-draft guidance (saving again keeps the old base), no stale "commit and push arrive in the next update" text or "commit/push later" token help. - Add cross-service workflow journeys: draft -> commit -> push -> fetch -> stack history and detail, audit without contents, a credential- rejected push that leaks no token and retries, and the documented manual recovery of an unpushed commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018E16JisaGNSCUUfrJpDmuZ --- AGENTS.md | 10 +- README.md | 14 +- docs/ARCHITECTURE.md | 9 +- docs/GIT_WORKFLOW.md | 174 ++++++++++++-- docs/INDEX.md | 5 +- docs/SECURITY.md | 12 +- docs/TESTING.md | 16 +- docs/domain/DATA_MODEL.md | 3 +- docs/getting-started.md | 38 +++- docs/index.md | 15 +- docs/installation.md | 6 +- docs/manifest.json | 14 +- docs/plans/MVP_PLAN.md | 29 ++- docs/plans/UI_UX_PLAN.md | 3 +- docs/providers/GIT_PROVIDER.md | 8 +- .../application/git-repository-service.ts | 2 +- src/server/domain/draft.ts | 4 +- src/server/persistence/schema.ts | 2 +- src/server/providers/git/registry.ts | 8 +- src/ui/source/changes-view.tsx | 92 +++++--- src/ui/source/doc-workspace.tsx | 3 +- src/ui/source/history-view.tsx | 12 +- src/ui/source/stack-editor.tsx | 8 +- tests/e2e/changes-commit.spec.ts | 3 +- tests/e2e/source.spec.ts | 2 +- .../integration/git-workflow-journey.test.ts | 214 ++++++++++++++++++ 26 files changed, 582 insertions(+), 124 deletions(-) create mode 100644 tests/integration/git-workflow-journey.test.ts diff --git a/AGENTS.md b/AGENTS.md index 01d4ae8..f4dacc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,13 +10,13 @@ This app manages Git-backed **source/configuration** for Compose stacks. It is * ## Current phase -**PR #4 — Git workflow** is the active product phase (commit, safe push, conflict protection, stack-scoped history, -Changes UI). PR #2 (application foundation) and PR #3 (source workspace) are done, including the dashboard, organised -Stacks list and settings split. Do not build deployment routing, secrets providers, runtime watch or PWA work ahead of -their phases. +**PR #5 — Deployment routing** is the next active product phase (DeploymentProvider, Portainer/generic webhooks, +forge webhook receiver, relevance rules, selective deploy). PR #2 (application foundation), PR #3 (source workspace) +and PR #4 (Git workflow: commit, safe push, conflict protection, stack history, Changes UI; released in v0.5.0) are +done. Do not build secrets providers, runtime watch or PWA work ahead of their phases. See [docs/plans/MVP_PLAN.md](docs/plans/MVP_PLAN.md) and the code layout in -[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#code-layout-implemented-in-pr-2). +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#code-layout-implemented-in-pr-2-extended-through-v050). ## Working in the code diff --git a/README.md b/README.md index 888bb30..18067e4 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,17 @@ It is **not** a Portainer / Komodo / Arcane / Docker UI replacement. Those syste Early development (0.x). -**Already available:** first-run setup, workspaces, encrypted credentials, Git repository connections +**Available today:** first-run setup, workspaces, encrypted credentials, Git repository connections (GitHub / Gitea / Forgejo over HTTPS), stacks, dashboard, organised Stacks list, settings, a VS Code-style editor with -drafts and Compose validation, stack docs, an environment inventory, and change review (diffs). +drafts and Compose validation, stack docs and an environment inventory. -**Coming in v0.5.0:** committing drafts, safe push, and Git history. Deployment routing (deploy only the stacks a -commit changed) follows the Git workflow phase; see the [roadmap](docs/plans/MVP_PLAN.md). +**New in v0.5.0, the Git workflow:** review drafts on the Changes tab, commit them with your own Git identity +(validation errors block, warnings need acknowledgement), and push safely to the tracked branch. Stack Manager never +force pushes, stops when the remote branch has changed, and keeps your drafts whatever happens. Each stack has a +History tab with the commits and diffs that touched it. See [Git workflow](docs/GIT_WORKFLOW.md). + +**Next:** deployment routing, which deploys only the stacks a commit changed through Portainer or a generic +webhook. See the [roadmap](docs/plans/MVP_PLAN.md). **Documentation:** [uniskela.com/docs/stack-manager](https://uniskela.com/docs/stack-manager/) (source in [docs/](docs/index.md)). @@ -26,6 +31,7 @@ commit changed) follows the Git workflow phase; see the [roadmap](docs/plans/MVP | --- | --- | | [Installation](docs/installation.md) | Run the published container image with Docker Compose | | [Getting started](docs/getting-started.md) | First run, connecting a repository, stacks and the editor | +| [Git workflow](docs/GIT_WORKFLOW.md) | Commit, push, remote changes and stack history | | [Self-hosting reference](docs/SELF_HOSTING.md) | Configuration, data directory, reverse proxy, upgrades | | [Product](docs/PRODUCT.md) / [Architecture](docs/ARCHITECTURE.md) | What it is (and is not), and how it is built | | [Docs index](docs/INDEX.md) | Every design, planning and operations document | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 80bf7d7..1ccdc26 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -117,7 +117,7 @@ Self-hosted env examples (names indicative): No design around ephemeral serverless filesystem. -## Code layout (implemented in PR #2) +## Code layout (implemented in PR #2, extended through v0.5.0) ```text src/ @@ -126,16 +126,19 @@ src/ api/…/route.ts # every handler wrapped by defineRoute (auth default-deny, origin check, zod) styles/ # tokens.css → base.css → components.css → shell.css (no inline styles; lint-enforced) shared/source/ # pure path rules, secret-file policy, Compose/YAML analysis (server + browser) + shared/git-*.ts # Git workflow and history response contracts shared with the Changes/History UI ui/ # UI components; may not import src/server/* primitives/ # Button, Alert, Section, PageHeader, Breadcrumbs, EmptyState, ConfirmButton, StatusPill shell/ # signed-in app shell: sidebar (wide) / top bar + drawer (narrow) - source/ # stack editor (CodeMirror 6), file tree, docs, changes, discovery + source/ # stack editor (CodeMirror 6), file tree, docs, changes (commit/push), history, discovery server/ config/ # env parsing and validation domain/ # entities, validation, errors — no framework/persistence imports application/ # services + persistence ports (ports.ts); depend on ports, not Drizzle + # (GitWorkflowService: draft commit/push; GitHistoryService: stack-scoped history) persistence/ # Drizzle schema; sqlite/ adapters implement the ports - providers/git/ # GitProvider capability interface, registry, hardened git CLI runner + providers/git/ # GitProvider interface, registry, hardened git CLI, isolated mutations, repository lock, + # history reader jobs/ # lease-based in-process worker (ADR 0002) security/ # AES-256-GCM secret box, Argon2id, session tokens, redaction, path + network policy observability/ # structured JSON logger (redacted) diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md index 7354b64..55b0824 100644 --- a/docs/GIT_WORKFLOW.md +++ b/docs/GIT_WORKFLOW.md @@ -1,17 +1,147 @@ -# Git editing, commit, and push workflow +# Git workflow: drafts, commits, push and history -## Happy path +Stack Manager turns your edits into ordinary Git commits on the repository's tracked branch. It never force +pushes, never merges or rebases on its own, and never deletes your drafts as part of a commit or push. The first +half of this page is a guide to the **Changes** and **History** tabs. The second half is the technical reference +and API contract. ```text -Edit → Validate → Commit → Push → (optional) Deploy → (optional) Watch +Edit → save draft → review Changes → validate → commit → push → fetch → History ``` +Deploying the stacks a commit touched, and watching the result, are later roadmap phases +([roadmap](plans/MVP_PLAN.md)). + +## Before your first commit + +- **Git identity.** Set your author name and email under **Settings → Account**. Stack Manager uses exactly this + identity for commits. It never guesses one from your username, token or server settings. Without it you can still + browse and save drafts, but **Commit** stays disabled. +- **Write access.** Pushing needs a token that can write to the repository. A read-only token is enough to browse, + draft and commit locally, but the push will be refused. + +## Draft to commit + +1. **Edit and save a draft.** In a stack's **Editor** or **Docs** tab, edit a file and choose **Save draft** + (Ctrl+S / ⌘S). Drafts are stored in Stack Manager's database. Your repository is not touched. +2. **Review Changes.** The **Changes** tab shows every draft as a line-by-line diff against the fetched commit. The + stack's Changes tab lists drafts inside that stack. The repository's Changes tab lists every draft in the + repository, including files outside stacks. The tab shows a count when drafts are waiting, and the stack header + shows a draft badge. +3. **Choose files.** Every draft starts selected. Untick any you want to leave out. Unselected drafts are not + validated or committed, and they stay as drafts. One commit can include up to 100 files. +4. **Validation.** The selected contents are checked with the same rules as the editor: + - **Errors** (for example, invalid YAML or a broken Compose file) always block the commit. Use the file links to + jump back to the editor and fix them. + - **Warnings** (including values that look like hard-coded secrets) need you to tick + **Commit despite warnings** first. Stack Manager records that override in the activity log. + - Informational notes never block. +5. **Write a commit message** and choose **Commit**, or **Commit & Push** to do both in one step. + +A successful commit is kept on the server as a local commit on the tracked branch, ready to push. Your drafts +**stay on the server** after a commit and after a push. Once you have checked the result, discard the drafts you no +longer need from the Changes tab. + +### Outdated drafts + +A draft remembers the version of the file it started from. If that file has changed since, on the remote or in a +commit you already made here, the draft is **outdated** and can't be committed. The editor and Changes tab mark it +**Changed upstream**. + +Saving the draft again does not move its starting point. To carry the edit forward, copy what you need from the +draft, discard it, and make the edit again on the current version. You can also untick outdated drafts and commit +the rest. + +## Push + +- Stack Manager pushes only to the repository's **tracked branch** (the default branch chosen when you connected it). +- Before pushing, it fetches the branch and checks that the remote is exactly where it was when you committed, and + that your commit builds directly on it. Only then does it send an ordinary push. +- It **never force pushes**, and there is no option to. If the remote has moved, the push is stopped before + anything is sent. +- After a successful push, choose **Fetch now** on the repository page to see the commit in **History** and to + refresh the editor's view of the branch. + +If **Commit & Push** commits but the push doesn't go through, the commit is kept. The Changes tab then offers +**Push** for that commit, so you don't have to commit the same drafts again. If you leave the tab before pushing, +choose **Commit** again. Stack Manager reports that the drafts are already in a local commit and offers **Push** +for it. + +## When the remote branch changed + +> The remote branch changed since your copy was last updated. + +This means someone (or another tool) pushed to the branch after Stack Manager last fetched it. Stack Manager stops +rather than overwrite their work. Nothing was overwritten, and your drafts are still safe. In Git terms, the push +would not be a fast-forward. + +- **If you haven't committed yet:** open the repository, choose **Fetch now**, then go back to Changes. Drafts for + files that changed remotely are now marked outdated (see above). Everything else can be committed on top of the + new remote version. +- **If your commit was already pushed** and others pushed after it: fetch, and keep working. New commits build on + the latest remote version. +- **If you have a local commit that was not pushed:** Stack Manager can't merge or rebase yet, so it keeps that + commit and won't create new commits on the branch until the commit is dealt with. Your drafts still hold the same + changes. See [Recovering an unpushed local commit](#recovering-an-unpushed-local-commit). + +## When a push fails + +Every failed Git operation leaves your drafts and any local commit exactly as they were. The Changes tab explains +what happened in plain language, with the technical reason as supporting detail: + +| You see | What it usually means | What to do | +| --- | --- | --- | +| The Git server refused the push | The token can't write to this branch, or a branch protection rule blocks direct pushes | Fix the permission or rule, then choose **Push** again | +| The Git server did not accept the saved credential | The token expired or was revoked | Replace it under **Settings → Credentials**, then push again | +| Could not reach the Git server / took too long | Network or server trouble | Try again later | +| Another Git operation is in progress | A fetch, commit or push is running for this repository | Wait a moment and try again | +| The branch changed while this was running | Another commit was made here at the same time | Refresh the page and review | + +Error messages never include tokens, remote URLs or raw Git output. + +## Stack history + +Each stack's **History** tab lists the commits that touched the stack's folder, newest first, with subject, short +SHA, author and time. Open a commit to see which files changed in this stack and a line-by-line diff of each. + +- History shows the repository **as of the last fetch**. A commit you just pushed appears after the next fetch. + Local commits that were never pushed don't appear. +- A commit that touches several stacks appears in each one, showing only that stack's files. +- Documentation changes count, as do commits made outside Stack Manager. +- Secret files may be listed by name, but their contents are never shown. Binary, very large and symlinked files + are listed without a diff. +- Renames show as a deletion at the old path and an addition at the new one. + +## Recovering an unpushed local commit + +If a local commit can't be pushed because the remote moved, Stack Manager keeps it and blocks new commits on that +branch. This is deliberate: it never discards a commit on its own. An in-app way to drop the local commit is planned. +Until then, an operator can remove it by hand. + +1. Make sure your drafts still hold the change. They are kept unless you discarded them. If not, note the commit + SHA shown in the Changes tab first. +2. Find the repository id in the repository page's address (`/w//repositories/`). +3. While no fetch, commit or push is running, remove the local commit reference: + + ```bash + docker compose exec stack-manager \ + git -C /data/repos/ update-ref -d refs/stack-manager/heads/ + ``` + + This only forgets the unpushed local commit. It doesn't change the remote, the fetched copy or your drafts. +4. Choose **Fetch now**, review your drafts on the Changes tab and commit again. + +# Reference + +The rest of this page describes how the workflow is implemented and the API the Changes and History tabs use. + ## Working tree / drafts - Edits are stored in SQLite `source_drafts`, keyed by repository and path. Database drafts remain the canonical uncommitted state; normal editing never dirties the Git clone. - Source reads use Git objects at the last synced commit. A draft is outdated when its `baseBlobSha` differs from the current tree entry (or a new path now exists). - The provider accepts only explicitly selected changes and never deletes or updates drafts. `GitWorkflowService` validates selected database snapshots and orchestrates commit and optional push. It checks draft base blobs against the retained local head, including earlier unpushed commits. - v0.5.0 preserves **all** drafts after success and failure, including edits saved concurrently with a commit. After a successful push, fetch the repository and review/discard committed drafts manually. An already committed draft can remain visible or be marked outdated; do not repeat the commit to retry a push. Automatic cleanup needs an atomic comparison against the exact committed draft snapshot and is deferred. +- Saving a draft keeps its original `baseBlobSha`, so re-saving an outdated draft does not make it committable. The operator discards it and edits the current version. A guided "update draft to the current version" flow is deferred. - Unsaved browser buffers warn on navigation; PR #8 adds offline support. ## Validation before commit @@ -24,9 +154,9 @@ Edit → Validate → Commit → Push → (optional) Deploy → (optional) Watch ## Diff review -- Stack-scoped diff (files under stack root + touched dependency paths) -- Full repo diff available for power users -- Line-level view for failure correlation later +- Stack Changes: drafts under the stack's root folder, as unified diffs against each draft's base. +- Repository Changes: every draft in the repository, including files outside stacks. +- Deployment dependency paths (shared files outside a stack's folder) are not yet part of the stack scope; they arrive with deployment routing. ## Commit @@ -36,19 +166,21 @@ Edit → Validate → Commit → Push → (optional) Deploy → (optional) Watch ## Push -- Push to tracked branch +- Push only to the repository's tracked (default) branch. - Before pushing, fetch and compare the expected remote SHA and ancestry. On rejection or a remote change, return structured state, retain the local commit and every draft, and permit a separate push retry only after manual review. -- Force push: not offered in MVP +- Force push is never used and is not offered. +- After a successful push, the retained ref stays on the pushed commit. Once a fetch has seen newer remote commits on top of it, branch inspection continues from the fetched head, so other people can keep pushing to the branch without blocking Stack Manager. ## Conflict protection - Detect remote ahead - v0.5.0 requires manual reconciliation outside the app. Fetch is available through the existing repository sync route; branch inspection also fetches remote refs without changing the source snapshot. Neither operation merges, rebases, moves the retained local head, or deletes drafts. +- An **unpushed** retained commit behind a moved remote blocks further commits on that branch until it is removed by hand (see [Recovering an unpushed local commit](#recovering-an-unpushed-local-commit)). An in-app discard action is a planned follow-up. - Block when remote commits would be lost. No hard reset, force push, or automatic merge/rebase is implemented. -## Implemented provider foundation (PR #4) +## Implementation overview (v0.5.0) -The low-level `GitProvider` supports `inspectBranch`, `commit`, `push`, and `getCommit` for all three smart-HTTP adapters. `GitWorkflowService` provides the application/API layer. The Changes tab commits selected drafts and can push or retry a retained commit. +The low-level `GitProvider` supports `inspectBranch`, `commit`, `push`, and `getCommit` for all three smart-HTTP adapters. `GitWorkflowService` provides the application/API layer. The Changes tab commits selected drafts and can push or retry a retained commit. The History tab reads the stack-scoped history API below. ## Application API contract (v0.5.0) @@ -221,22 +353,24 @@ history/scope; 409 `not_synced` before fetch; 409 `history_changed` for stale an 502 `git_history_failed` for bounded or failed Git reads. Error messages never include Git output. Commit/deployment correlation remains later work. -## Optional branch / PR architecture - -Where `GitProvider` capabilities allow: +## Branch / PR workflow (deferred) -- Create branch from draft -- Open PR toward default branch -- Deploy policy may require merge to default branch (configurable) +v0.5.0 commits and pushes directly to the tracked branch only. A forge branch and pull request flow was optional for +this phase and did not ship. If added later, it would be capability-gated per `GitProvider`: -MVP may ship commit-to-default-branch first; PR flow is optional enhancement inside PR #4 if timeboxed. +- Create a branch from selected drafts +- Open a PR toward the default branch +- Deployment policy may require a merge to the default branch (configurable) ## Security -- Do not commit `.env` with secrets; warn on matched secret file patterns -- Pre-commit checklist for credential-looking strings (heuristic) +- Secret-looking files (`.env*` except templates, keys, certificates, `secrets/`) can never be drafted, selected or committed; their contents never reach the browser, drafts, history diffs or commits. +- Values that look like hard-coded secrets in Compose files are warnings that need explicit acknowledgement. The override is audited without the value. +- Tokens are sent to `git` as a per-process HTTP header, never in remote URLs, argv, `.git/config`, logs, audit events or API responses. Workflow failures expose only a coarse `reason` code. +- No force push, mirror push, tag push, hooks or commit signing. No merge, rebase or hard reset. +- Failed or rejected Git operations never delete drafts or the retained local commit. ## Non-goals -- Full IDE merge tool competing with VS Code +- Full IDE merge tool competing with VS Code (an in-app conflict editor is not planned for v0.5.0) - Hosting the Git server diff --git a/docs/INDEX.md b/docs/INDEX.md index c5f54e8..27353a8 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -10,7 +10,8 @@ are listed in [manifest.json](manifest.json) (see [../.github/docs-sync.md](../. | --- | --- | | [index.md](index.md) | Overview: what stack-manager does and does not do | | [installation.md](installation.md) | Docker Compose install, image tags, upgrades | -| [getting-started.md](getting-started.md) | First run, repositories, stacks, editor and drafts | +| [getting-started.md](getting-started.md) | First run, repositories, stacks, editor, drafts, commit and push | +| [GIT_WORKFLOW.md](GIT_WORKFLOW.md) | Commit, safe push, remote changes, stack history; API reference | ## Product and architecture @@ -36,7 +37,7 @@ are listed in [manifest.json](manifest.json) (see [../.github/docs-sync.md](../. | [providers/SECRET_PROVIDER.md](providers/SECRET_PROVIDER.md) | Secret metadata / references | | [STACK_DISCOVERY.md](STACK_DISCOVERY.md) | Repository → stack scopes | | [DEPLOYMENT_ROUTING.md](DEPLOYMENT_ROUTING.md) | Path relevance and push router | -| [GIT_WORKFLOW.md](GIT_WORKFLOW.md) | Edit → validate → commit → push | +| [GIT_WORKFLOW.md](GIT_WORKFLOW.md) | Edit → validate → commit → push → history (implemented in v0.5.0) | | [DEPLOYMENT_WATCH.md](DEPLOYMENT_WATCH.md) | CI-like deploy feedback | ## Operations and UX diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 270fe9e..8134999 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -43,7 +43,9 @@ Assume: operator places stack-manager on a trusted homelab network behind auth a | CSRF | SameSite sessions + origin checks on mutating routes | | XSS in log viewer | Treat logs as untrusted text; strict encoding | | Path traversal in git file APIs | Repo paths normalised and scoped to the stack folder; `..`, `.git` and control characters rejected; content read from Git objects (symlinks are listed, never followed) | -| Secret files in repositories | `.env*` (except templates), keys/keystores and `secrets/` paths are shown as locked; contents are never returned to the browser or stored as drafts | +| Secret files in repositories | `.env*` (except templates), keys/keystores and `secrets/` paths are shown as locked; contents are never returned to the browser, stored as drafts, committed or shown in history diffs | +| Lost remote work via push | Push sends one exact SHA-to-branch refspec after re-checking the expected remote head and ancestry; never force, mirror or tag push; no merge, rebase or reset | +| Lost local work on Git failure | Commit and push never delete or rewrite drafts; failed or rejected pushes keep the local commit for review and retry | | XSS via repository Markdown | Rendered without raw HTML; unsafe URLs dropped; relative links mapped to stack routes; only https images, no referrer | ## Explicit non-features (security-relevant) @@ -64,8 +66,10 @@ Assume: operator places stack-manager on a trusted homelab network behind auth a - [x] Container image scanning: Trivy gate on fixable HIGH/CRITICAL for PRs touching the image, weekly, and before every release publish; Dockerfile misconfiguration scan; runtime image ships without npm/npx/corepack/yarn - [x] Pinned GitHub Actions (commit SHAs) with Dependabot updates; release images carry SBOM + provenance +- [x] Git workflow failure tests (PR #4): tokens, credential-bearing URLs and source values absent from responses, + logs and audit after commit/push/auth failures; secret paths refused for commit -## Controls implemented in PR #2 +## Controls implemented in PR #2 and the Git workflow (PR #4) - **Git invocation:** `git` is spawned with an argv array (no shell), `--` before URLs, validated branch names (no leading `-`), a protocol allowlist (`https` only), hooks disabled, no system/global config, an isolated @@ -75,6 +79,10 @@ Assume: operator places stack-manager on a trusted homelab network behind auth a Mutation operations disable signing, filesystem-monitor commands and automatic maintenance. Selected contents are hashed without clean filters; hooks never run. - **Git mutation:** detached temporary worktrees have isolated indexes and no checkout. Canonical selected paths are verified against Git objects; symlinks, submodules, secrets and file/directory collisions are refused. Expected local/remote heads, base blobs and ancestry are checked under a repository lock. Push uses an exact SHA-to-branch refspec with no force or mirror option. Cancellation does not cancel cleanup; provider operations never delete drafts. +- **Git workflow responses and audit:** workflow results carry status, SHAs, branch state and a coarse `reason` + code only. Never provider exception text, stderr, remote URLs, commit messages, identities or draft contents. + Validation problems carry path, position, severity and a stable code; parser messages, which can quote source + values, are omitted. Commit authors come only from the signed-in user's saved Git identity. - **Remote URL policy:** HTTPS only; embedded credentials, query strings and traversal segments are rejected. Resolved addresses must not be loopback, link-local/metadata or multicast — including IPv4 embedded in IPv6 (mapped, compatible, translated and NAT64 forms, dotted or hex); private ranges require diff --git a/docs/TESTING.md b/docs/TESTING.md index 8263c43..d4fad91 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,7 +23,7 @@ Protect product boundary, credential safety, routing correctness, and editor/wor 5. **Credentials API:** GET never returns secret material 6. **SSRF:** blocked addresses rejected 7. **Watch:** state machine transitions with mocked runtime snapshots -8. **Git:** conflict when remote ahead (fixture repos) +8. **Git:** conflict when remote ahead (fixture repos), no force push, drafts preserved on every failure ## Implemented in PR #2 @@ -45,6 +45,20 @@ Protect product boundary, credential safety, routing correctness, and editor/wor unit tests cover path rules and the Compose analyser. With `E2E_GIT_REMOTE` and `E2E_STACK_ROOT` set (CI uses `docker/awesome-compose` / `nginx-golang`, since the remote must be public), the Playwright suite also opens the editor, saves a draft, reviews the diff and checks every stack tab with axe. +- **Git workflow (PR #4, v0.5.0):** integration tests run real commits and pushes against the local smart-HTTP + server: + - `git-mutations.test.ts`: provider isolation, locking, path refusal, ancestry checks and cleanup. + - `git-workflow.test.ts`: every workflow outcome (validation errors and warnings, outdated drafts, missing identity, + remote changes before and after a commit, real push rejection, infrastructure failures with secret scrubbing, + access control, and continuing after a pushed commit is built on elsewhere). + - `git-history*.test.ts`: history scoping, pagination, limits and locked content. + - `git-workflow-journey.test.ts`: the cross-service journeys. Draft → commit → push → fetch → stack history and + detail, audit with stack ids but no contents, a credential-rejected push that leaks no token and retries, and + the documented manual recovery of an unpushed commit. + + Playwright (`changes-commit.spec.ts`, `git-workflow` projects) covers the Changes UI: missing identity, validation + links, a real local commit, mocked remote-changed and push-success responses, Push recovery for an earlier local + commit, and axe on desktop and mobile. `source.spec.ts` covers the History tab and draft indicators. ## CI diff --git a/docs/domain/DATA_MODEL.md b/docs/domain/DATA_MODEL.md index 5f462e9..328c4e2 100644 --- a/docs/domain/DATA_MODEL.md +++ b/docs/domain/DATA_MODEL.md @@ -227,7 +227,8 @@ straightforward. Additions beyond the logical model above: - Committed content is read from Git objects of the fetched `origin/` commit, never from the working tree. - A draft is **outdated** when the committed blob at its path no longer matches `baseBlobSha`. - Secret-looking paths (`.env`, keys, `secrets/`) are never accepted as drafts, so no secret values are stored. -- PR #4 turns drafts into commits (and deletes them once pushed), with conflict handling for outdated drafts. +- The Git workflow (v0.5.0) commits selected drafts and pushes them, refusing outdated drafts. Drafts are never + deleted by a commit or push, successful or not; the operator discards them after reviewing the pushed result. ## Non-entities (explicitly out of core model) diff --git a/docs/getting-started.md b/docs/getting-started.md index 35093a1..aed2057 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -81,7 +81,7 @@ Each stack has these tabs: | **Docs** | Read the stack's Markdown files rendered (README first), follow links between them and edit them as drafts. | | **Environment** | Variables the Compose file references, whether `.env.example` documents them, env files it expects, and values that look like hard-coded secrets. Values are never shown. | | **Changes** | Every draft as a line-by-line diff against the fetched commit. Select files, commit with your Account Git identity, and push when ready. Discard drafts you don't want. | -| **History** | Commits that touched this stack at the last fetched snapshot. Open a commit to inspect the scoped file diffs. | +| **History** | Commits that touched this stack, as of the last fetch. Open a commit to see what changed in this stack's files. | | **Settings** | Rename the stack, change its Compose file or remove it (files and drafts are kept). | Files that look like secrets (`.env`, private keys, certificates, anything in a `secrets/` folder) are listed but @@ -89,13 +89,30 @@ can't be opened or edited. ### Drafts -Drafts are stored in stack-manager's database and never leave the server. If the file changes in Git after you start a -draft, the editor warns you, so you don't overwrite someone else's change unknowingly. +Drafts are stored in stack-manager's database and stay on the server until you commit them. If the file changes in +Git after you start a draft, the editor marks it **Changed upstream** and it can't be committed. That way you can't +overwrite someone else's change by accident. Saving again doesn't fix this: copy what you need, discard the draft and +make the edit again on the current version. -Set your **Git identity** under Settings → Account before the first commit. After a successful push, drafts stay -on the server until you discard them — fetch the repository if the remote moved ahead. +## 5. Commit and push -## 5. Files outside stacks +1. Set your **Git identity** (name and email) under **Settings → Account**. Commits use exactly this identity. +2. Open the stack's **Changes** tab, or the repository's **Changes** tab for every draft in the repository. A count + on the tab and a badge in the stack header show when drafts are waiting. +3. Review each diff. Untick any draft you want to leave out of this commit. +4. Fix any **errors** the validation shows (they always block the commit). If there are **warnings**, such as a value + that looks like a hard-coded secret, review them and tick **Commit despite warnings**. +5. Write a commit message, then choose **Commit**, or **Commit & Push** to send it to your Git server straight away. + Pushing needs a token with write access. +6. After pushing, choose **Fetch now** on the repository page. The commit then appears on the stack's **History** + tab. Discard the drafts you no longer need. + +Stack Manager pushes only to the repository's tracked branch and **never force pushes**. If someone else pushed +first, you'll see *"The remote branch changed since your copy was last updated."* Nothing is overwritten, and your +drafts and any local commit are kept. Fetch, review, and commit again. A failed or refused push never discards your +work either. See [Git workflow](GIT_WORKFLOW.md) for what each message means and how to recover. + +## 6. Files outside stacks Not everything lives in a stack folder: a root `README.md`, a `docs/` folder or a shared `.env.example` belong to the whole repository. The repository page has tabs for them: @@ -105,9 +122,10 @@ whole repository. The repository page has tabs for them: | **Overview** | Fetch status, stacks, branches and the credential. | | **Files** | The whole repository in the same editor as a stack, with the same draft and secret-file rules. | | **Docs** | Every Markdown file in the repository, grouped by folder, rendered and editable as drafts. | -| **Changes** | Every draft in the repository, including the ones made inside stacks. | +| **Changes** | Every draft in the repository, including the ones made inside stacks, with the same commit and push controls. | -## 6. Keep it up to date +## 7. Keep it up to date -Use **Fetch now** on the repository page to pull the latest commits from your Git server. The editor, docs and environment -views always read from the fetched commit plus your drafts. +Use **Fetch now** on the repository page to pull the latest commits from your Git server. The editor, docs, +environment and history views always read from the fetched commit, plus your drafts. Fetching never changes or +deletes drafts. diff --git a/docs/index.md b/docs/index.md index 95630d2..3082105 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,11 @@ similar tool. stack-manager is the place where you **edit and review that source - **Docs:** read each stack's Markdown documentation next to its configuration. - **Environment:** see which variables a stack uses, whether `.env.example` documents them, which env files it expects and which values look like hard-coded secrets. Values are never shown. -- **Changes:** review every draft as a diff against the fetched commit before it goes anywhere. +- **Changes:** review every draft as a diff against the fetched commit, then commit the files you pick with your own + Git identity. Validation errors block a commit; warnings need your confirmation. +- **Safe push:** push to the repository's tracked branch. Stack Manager never force pushes. If someone else pushed + first, it stops and tells you, and your drafts are always kept. +- **History:** each stack lists the commits that touched it, with a diff of each change. Secret files such as `.env`, keys and certificates are never displayed or editable, and provider tokens are stored encrypted. @@ -33,11 +37,11 @@ stack-manager looks after the Git source they are deployed from. ## Status stack-manager is in early development (0.x). Available today: sign-in, workspaces, encrypted credentials, repository -connections, stacks, dashboard and settings, the editor with drafts, docs, the environment inventory and change review. +connections, stacks, dashboard and settings, the editor with drafts, docs, the environment inventory, and the Git +workflow (v0.5.0): change review, commit, safe push and stack history. -Stack-scoped Git history is on each stack's History tab. Draft commit and safe push are on Changes. Next on the -[roadmap](plans/MVP_PLAN.md): -deploying only the stacks a commit changed (through Portainer and others), then optional read-only runtime feedback. +Next on the [roadmap](plans/MVP_PLAN.md): deploying only the stacks a commit changed (through Portainer and others), +then optional read-only runtime feedback. ## Documentation @@ -45,6 +49,7 @@ deploying only the stacks a commit changed (through Portainer and others), then - [Installation](installation.md): run the container with Docker Compose - [Getting started](getting-started.md): first run, connecting a repository, adding stacks and editing +- [Git workflow](GIT_WORKFLOW.md): committing, pushing, what to do when the remote changed, and stack history **Deploy and operate** diff --git a/docs/installation.md b/docs/installation.md index 9b55ea1..87beef8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,7 +7,7 @@ access to the Docker socket. - Docker with the Compose plugin, on a linux/amd64 host - A Git repository with your Compose files on GitHub, Gitea or Forgejo, reachable over HTTPS -- An access token for that repository (read access for source/drafts; write access for the push API) +- An access token for that repository (read access to browse and draft; write access to push commits) - Ideally a reverse proxy that terminates TLS, such as Caddy, Traefik or Nginx Proxy Manager ## 1. Get the Compose file @@ -45,8 +45,8 @@ Optional settings you are likely to want: - `STACK_MANAGER_COOKIE_SECURE=false`: only for testing over plain HTTP on localhost After sign-in, set your Git author name and email under **Settings → Account**. Commits created by Stack Manager -use that identity; they do not grant repository permissions. See the -[Git workflow API contract](GIT_WORKFLOW.md#application-api-contract-v050). +use that identity; it does not grant repository permissions. See [Git workflow](GIT_WORKFLOW.md) for committing, +pushing and history. Every variable is described in the [self-hosting reference](SELF_HOSTING.md#configuration). diff --git a/docs/manifest.json b/docs/manifest.json index ab99477..3d637ff 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -27,6 +27,13 @@ "group": "Start here", "kind": "doc" }, + { + "source": "docs/GIT_WORKFLOW.md", + "slug": "git-workflow", + "title": "Git workflow: commit, push and history", + "group": "Start here", + "kind": "doc" + }, { "source": "docs/SELF_HOSTING.md", "slug": "self-hosting", @@ -69,13 +76,6 @@ "group": "Project", "kind": "doc" }, - { - "source": "docs/GIT_WORKFLOW.md", - "slug": "git-workflow", - "title": "Git workflow and API", - "group": "Project", - "kind": "doc" - }, { "source": "docs/ARCHITECTURE.md", "slug": "architecture", diff --git a/docs/plans/MVP_PLAN.md b/docs/plans/MVP_PLAN.md index 7724583..ef44c41 100644 --- a/docs/plans/MVP_PLAN.md +++ b/docs/plans/MVP_PLAN.md @@ -1,8 +1,7 @@ # Phased MVP implementation plan -PR #1 (documentation), PR #2 (application foundation) and PR #3 (source workspace) are merged, including the later -dashboard / organised Stacks / settings slice. **PR #4 (Git workflow) is the active phase.** Subsequent PRs implement -the product. Boundaries may shift for strong technical reasons; avoid giant cross-cutting PRs. +PR #1 (documentation), PR #2 (application foundation), PR #3 (source workspace) and PR #4 (Git workflow, released as +v0.5.0) are complete. **PR #5 (deployment routing) is the next active phase.** Subsequent PRs implement the product. Boundaries may shift for strong technical reasons; avoid giant cross-cutting PRs. ## PR sequence @@ -56,19 +55,27 @@ the product. Boundaries may shift for strong technical reasons; avoid giant cros - [x] Unit, integration and Playwright + axe tests (desktop/mobile, light/dark) - Deferred to PR #4: commit/push of drafts, conflict resolution, stack-scoped history. Deferred: `docker compose config` validation (needs a Docker binary), docs globs per stack, command palette -## PR #4 exit criteria +## PR #4 exit criteria (complete, v0.5.0) -Server/API pieces already on `main` (PRs #29, #31, #32): isolated commit/push foundation, draft commit + safe push -workflow APIs, and stack-scoped history. Remaining product work for this phase is the Changes UI and any follow-up -hardening called out in [GIT_WORKFLOW.md](../GIT_WORKFLOW.md). Do not treat commit/push as user-complete until those -UI paths ship; do not pull PR #5+ forward. +Shipped across repository PRs #29, #31, #32, #34, #35, #36 and #37. User guide and API contract: +[GIT_WORKFLOW.md](../GIT_WORKFLOW.md). - [x] Hardened Git mutation foundation (`inspectBranch` / `commit` / `push` / `getCommit`; no force push) - [x] Application commit + safe-push workflow API with validation gates and conflict protection +- [x] Per-user Git author identity under Settings → Account; commits are blocked without it - [x] Stack-scoped (and repository) history API at the fetched source snapshot -- [ ] Changes UI: select drafts, commit message, acknowledge warnings, commit and explicit push/retry -- [ ] Optional forge branch/PR flow (timeboxed; flag if large) -- Deferred: automatic draft cleanup after push, in-app merge/rebase, SSH remotes +- [x] Changes UI: select drafts, commit message, acknowledge warnings, commit and explicit push/retry +- [x] Stack History tab with commit detail and diffs; draft counts on the Changes tab and stack header +- [x] Workflow-boundary tests: draft → commit → push → fetch → history, audit, credential-free failures +- Not shipped (optional): forge branch/PR flow +- Deferred: automatic draft cleanup after push, "update draft to the current version", in-app discard of an + unpushed local commit, in-app merge/rebase or conflict editor, SSH remotes + +## PR #5 (next): deployment routing + +Next active phase. Scope as in the table above: `DeploymentProvider` with Portainer and generic webhooks, a forge +webhook receiver, relevance rules, selective deploy, idempotency and audit. Do not expand it into Portainer management +UI. ## Dependency graph diff --git a/docs/plans/UI_UX_PLAN.md b/docs/plans/UI_UX_PLAN.md index 5ffad61..c18a4e3 100644 --- a/docs/plans/UI_UX_PLAN.md +++ b/docs/plans/UI_UX_PLAN.md @@ -148,7 +148,8 @@ These follow the PR sequence and don't come earlier: - **Command palette** (`⌘K` / `Ctrl K`) for jumping to repositories and stacks and running actions (Fetch, Test). This is already listed in [UX.md](../UX.md); build it once stacks exist to navigate (PR #3). - **Keyboard shortcuts**: `g r` repositories, `g s` settings, `/` focus search, `?` shortcut sheet. - **Editor chrome** (PR #3): tabs, file tree, problems panel and bottom sheet on mobile, as in [UX.md](../UX.md). They should reuse the UI-1 primitives. **Done in PR #3** (on mobile the explorer is an overlay and problems start collapsed). -- **Changes indicator** (PR #4): an uncommitted-changes badge in the sidebar and page header. +- **Changes indicator** (PR #4): an uncommitted-changes badge in the sidebar and page header. *Implemented in v0.5.0 + as a draft count on the Changes tab and a draft badge in the stack header; a sidebar badge remains open.* ### Phase UI-5 — Professional finish (PR #8) diff --git a/docs/providers/GIT_PROVIDER.md b/docs/providers/GIT_PROVIDER.md index 56a3def..450a12c 100644 --- a/docs/providers/GIT_PROVIDER.md +++ b/docs/providers/GIT_PROVIDER.md @@ -66,7 +66,7 @@ One repository webhook → stack-manager: - Never log remote URLs with embedded tokens - Support read-only vs write scopes; UI should explain required scopes for commit/push vs webhook-only -## Implementation status (Git workflow foundation) +## Implementation status (v0.5.0) - `GitProvider` (`src/server/providers/git/types.ts`) currently exposes the foundation subset: `testConnection` (via `git ls-remote --symref`), `syncClone` (atomic clone into a temp dir then rename, or @@ -81,8 +81,12 @@ One repository webhook → stack-manager: - `webhookCredentialId` exists in the schema and stays null until PR #5. - The local clone is a normal read clone, not a bare mirror. SQLite drafts remain canonical. Commits use detached temporary worktrees with isolated indexes and no checkout; pending heads are retained under private refs. - All mutation/fetch operations share an exclusive repository lock. Production adapters receive the allowed repositories directory from the composition root; mutation paths and Git metadata cannot escape it. Remote host policy remains the application caller's responsibility, as for sync; the provider additionally validates HTTPS URLs without embedded credentials. +- Push never uses force, mirror or tag options. Pushing requires write access in the token; the UI explains a refused + push without returning provider output. +- Forge pull requests and commit statuses are not implemented (`capabilities.pullRequests` / `commitStatuses` are + `false`); the v0.5.0 workflow commits directly to the tracked branch. - Commit inputs map selected drafts to `{ path, content, baseBlobSha }` with an expected branch HEAD and author identity; `content: null` supports controlled deletion. Push requires an exact retained commit and expected remote SHA. Neither operation touches draft persistence. -- See [Git workflow](../GIT_WORKFLOW.md#implemented-provider-foundation-pr-4) for remote protection, cleanup and crash-recovery limits. +- See [Git workflow](../GIT_WORKFLOW.md#implementation-overview-v050) for remote protection, cleanup and crash-recovery limits. ## Non-goals diff --git a/src/server/application/git-repository-service.ts b/src/server/application/git-repository-service.ts index 37a350d..30ec94e 100644 --- a/src/server/application/git-repository-service.ts +++ b/src/server/application/git-repository-service.ts @@ -67,7 +67,7 @@ export interface GitRepositoryServiceOptions { /** * Repository connections: validation, access tests, clone/fetch under the data directory and - * branch metadata. Commit/push UX is PR #4; this service only keeps a local mirror current. + * branch metadata. Commit/push lives in GitWorkflowService; this service only keeps a local mirror current. */ export class GitRepositoryService { constructor( diff --git a/src/server/domain/draft.ts b/src/server/domain/draft.ts index d3f8124..5b1c94a 100644 --- a/src/server/domain/draft.ts +++ b/src/server/domain/draft.ts @@ -1,6 +1,6 @@ /** - * An unsaved-to-Git edit of one repository file (PR #3). Drafts live in the database until PR #4 turns - * them into commits. They are keyed by repository + path (not stack) because stacks may overlap. + * An unsaved-to-Git edit of one repository file. Drafts live in the database; the Git workflow commits + * selected drafts but never deletes them. They are keyed by repository + path (not stack) because stacks may overlap. */ export interface SourceDraft { id: string; diff --git a/src/server/persistence/schema.ts b/src/server/persistence/schema.ts index bd3b37d..c584b8f 100644 --- a/src/server/persistence/schema.ts +++ b/src/server/persistence/schema.ts @@ -198,7 +198,7 @@ export const stacks = sqliteTable( ], ); -/** PR #3: pending edits to repository files. PR #4 turns them into commits. */ +/** Pending edits to repository files; the Git workflow commits selected drafts without deleting them. */ export const sourceDrafts = sqliteTable( 'source_drafts', { diff --git a/src/server/providers/git/registry.ts b/src/server/providers/git/registry.ts index 45e8f00..c4cd52d 100644 --- a/src/server/providers/git/registry.ts +++ b/src/server/providers/git/registry.ts @@ -10,21 +10,23 @@ export const BUILTIN_GIT_PROVIDERS: readonly GitProviderDescriptor[] = [ displayName: 'GitHub', exampleUrl: 'https://github.com/your-org/compose-stacks.git', tokenHelp: - 'Fine-grained token with Contents: read (read-only) or read and write (for commit/push later).', + 'Fine-grained token with Contents: read (browse and draft) or read and write (also push commits).', capabilities: { pullRequests: false, commitStatuses: false, webhooks: false }, }, { type: 'gitea', displayName: 'Gitea', exampleUrl: 'https://gitea.example.com/your-org/compose-stacks.git', - tokenHelp: 'Access token with repository read scope (write scope for commit/push later).', + tokenHelp: + 'Access token with repository read scope (browse and draft), plus write scope to push commits.', capabilities: { pullRequests: false, commitStatuses: false, webhooks: false }, }, { type: 'forgejo', displayName: 'Forgejo', exampleUrl: 'https://forgejo.example.com/your-org/compose-stacks.git', - tokenHelp: 'Access token with repository read scope (write scope for commit/push later).', + tokenHelp: + 'Access token with repository read scope (browse and draft), plus write scope to push commits.', capabilities: { pullRequests: false, commitStatuses: false, webhooks: false }, }, ]; diff --git a/src/ui/source/changes-view.tsx b/src/ui/source/changes-view.tsx index f93e510..84e1d52 100644 --- a/src/ui/source/changes-view.tsx +++ b/src/ui/source/changes-view.tsx @@ -28,6 +28,18 @@ function Diff({ before, after }: { before: string; after: string }) { return ; } +const FAILURE_REASON: Record, string> = { + auth: 'The Git server did not accept the saved credential. Check that the token is valid and can write to this repository.', + not_found: 'The repository or branch could not be found on the Git server.', + network: 'Stack Manager could not reach the Git server.', + timeout: 'The Git server took too long to respond.', + invalid: 'The Git server or local copy returned something unexpected.', + conflict: 'The branch changed while this was running.', + busy: 'Another Git operation was running.', + rejected: 'The Git server refused the change.', + unknown: 'Something went wrong while talking to Git.', +}; + function problemLabel(problem: GitWorkflowProblem) { if (problem.code === 'hardcoded-secret') return 'Hard-coded secret'; if (problem.severity === 'error') return 'Error'; @@ -52,14 +64,15 @@ function WorkflowAlert(props: { if (result.status === 'push_succeeded' && result.commitSha) { return ( - Drafts are still on this server — review or discard them after you confirm the remote looks right. + Your drafts are still on this server. Fetch the repository to see the commit in History, then discard + the drafts you no longer need. ); } if (result.status === 'commit_succeeded' && result.commitSha) { return ( - Drafts remain until you discard them. You can push this commit when ready. + It hasn't been pushed yet. Your drafts stay here until you discard them. ); } @@ -101,7 +114,8 @@ function WorkflowAlert(props: { if (result.status === 'git_identity_missing') { return ( - Set your name and email under Account settings before committing. Reads and drafts still work. + Set your name and email under Account settings before committing. You can still browse files and save + drafts.
Open Git identity settings @@ -113,8 +127,9 @@ function WorkflowAlert(props: { if (result.status === 'draft_outdated') { return ( - These files changed upstream since the draft was started. Open them, review, and save again before - committing:{' '} + These files changed after the draft was started, either on the remote or in a commit you already made + here. Saving again keeps the old starting point, so copy what you need, discard the draft and make the + edit again on the current version:{' '} {result.outdatedPaths.map((path, i) => ( {i > 0 ? ', ' : ''} @@ -128,40 +143,52 @@ function WorkflowAlert(props: { if (result.status === 'remote_changed') { return ( - Nothing was overwritten and your drafts are still safe. Fetching only refreshes remote refs; it does - not reconcile a retained commit. Manual reconciliation must happen outside this app. - {result.commitSha ? Local commit {shortSha(result.commitSha)} is retained. : null} + Nothing was overwritten and your drafts are still safe. Stack Manager never force pushes. + {result.state && result.state.ahead > 0 ? ( + + {' '} + {result.commitSha + ? `Local commit ${shortSha(result.commitSha)} was not pushed.` + : `${result.state.ahead} local commit${result.state.ahead === 1 ? ' was' : 's were'} not pushed.`}{' '} + Stack Manager can't merge or rebase yet, so this work needs to be brought across by hand. The + Git workflow guide explains how. + + ) : ( + Fetch the repository, review your drafts against the new version, then commit again. + )}
Open repository to fetch
- {result.reason ? ( -

Technical detail: {result.reason} (non-fast-forward).

+ {result.state && result.state.behind > 0 ? ( +

+ Technical detail: the remote has {result.state.behind} commit + {result.state.behind === 1 ? '' : 's'} your copy doesn't, so a push would not be a + fast-forward. +

) : null}
); } if (result.status === 'push_rejected') { return ( - - Nothing was overwritten and your drafts are still safe + + This usually means the access token can't write to this branch, or a branch protection rule + blocks direct pushes. Nothing was overwritten and your drafts are still safe. {result.commitSha ? ( {' '} - — local commit {shortSha(result.commitSha)} is retained for retry after you review permissions or - policy. + Local commit {shortSha(result.commitSha)} is kept, so you can push it again once that's + fixed. - ) : ( - '.' - )} - {result.reason ?

Technical detail: {result.reason}.

: null} + ) : null}
); } if (result.status === 'branch_changed') { return ( - - Nothing was overwritten and your drafts are still safe. Refresh and review before trying again. - {result.commitSha ? Local commit {shortSha(result.commitSha)} is retained. : null} + + Another commit was made here, or this commit is no longer the latest one. Nothing was overwritten and + your drafts are still safe. Refresh the page and review before trying again. ); } @@ -175,9 +202,10 @@ function WorkflowAlert(props: { if (result.status === 'git_operation_failed') { return ( - Your drafts are still safe - {result.commitSha ? and local commit {shortSha(result.commitSha)} is retained. : '.'} - {result.reason ?

Reason: {result.reason}.

: null} + {result.reason ? `${FAILURE_REASON[result.reason]} ` : ''}Nothing was overwritten and your drafts are + still safe + {result.commitSha ? ; local commit {shortSha(result.commitSha)} is kept. : '.'} + {result.reason ?

Technical detail: {result.reason}.

: null}
); } @@ -414,7 +442,8 @@ export function ChangesView(props: { {!props.gitIdentity ? ( - Set your name and email under Account settings before committing. Reads and drafts still work. + Set your name and email under Account settings before committing. You can still browse files and + save drafts.
Open Git identity settings @@ -446,7 +475,7 @@ export function ChangesView(props: { result.status !== 'draft_outdated' && result.status !== 'git_identity_missing' ? ( - Committed as {shortSha(result.commitSha)}. Your drafts are still on this server. + Committed as {shortSha(result.commitSha)}, but not pushed. Your drafts are still on this server. ) : null}
@@ -454,7 +483,7 @@ export function ChangesView(props: {
- {props.changes.length === 0 && retained ? 'Push retained commit' : 'Commit'} + {props.changes.length === 0 && retained ? 'Push local commit' : 'Commit'}
{props.changes.length === 0 && retained ? ( <> @@ -482,7 +511,7 @@ export function ChangesView(props: { {props.changes.length === 0 && retained ? (

- Drafts for this change set were discarded. The retained commit is still available to push. + The drafts were discarded, but this local commit is still waiting to be pushed.

) : null} - {limitReached ?

History pagination limit reached for this fetch.

: null} + {limitReached ? ( +

+ Older commits are not listed here. Use your Git server to browse further back. +

+ ) : null}
); } diff --git a/src/ui/source/stack-editor.tsx b/src/ui/source/stack-editor.tsx index e51202d..4926a39 100644 --- a/src/ui/source/stack-editor.tsx +++ b/src/ui/source/stack-editor.tsx @@ -85,7 +85,7 @@ function tabFromFile(file: FileView): Tab { /** * VS Code-style editor for one stack (or a whole repository): explorer, tabs, CodeMirror, live problems and a status bar. - * Saving stores a draft on the server (PR #3); committing and pushing drafts arrives in PR #4. + * Saving stores a draft on the server; drafts are committed and pushed from the Changes tab. */ export function StackEditor(props: StackEditorProps) { const router = useRouter(); @@ -449,8 +449,8 @@ export function StackEditor(props: StackEditorProps) { {active?.file?.draft?.outdated ? (
- The committed version was updated after this draft was started. Review the draft before - committing. + The committed version was updated after this draft was started, so it can't be committed. + Copy what you need, discard the draft and make the edit again on the current version.
) : null} @@ -460,7 +460,7 @@ export function StackEditor(props: StackEditorProps) {

Select a file in the explorer to start editing.

- Edits are saved as drafts. Commit and push arrive in the next update. + Edits are saved as drafts. Review, commit and push them on the Changes tab.

diff --git a/tests/e2e/changes-commit.spec.ts b/tests/e2e/changes-commit.spec.ts index 05e184b..c30b6f4 100644 --- a/tests/e2e/changes-commit.spec.ts +++ b/tests/e2e/changes-commit.spec.ts @@ -210,7 +210,8 @@ test('remote-changed push keeps work and offers fetch', async ({ page }) => { await expect(page.getByText('The remote branch changed since your copy was last updated.')).toBeVisible(); await expect(page.getByText(/drafts are still safe/i)).toBeVisible(); - await expect(page.getByText(/does not reconcile a retained commit/i)).toBeVisible(); + await expect(page.getByText(/never force pushes/i)).toBeVisible(); + await expect(page.getByText(/aaaaaaa was not pushed/)).toBeVisible(); await expect(page.getByRole('link', { name: 'Open repository to fetch' })).toBeVisible(); await expectWorkPreserved(page); await expect(page).toHaveTitle(/Changes/); diff --git a/tests/e2e/source.spec.ts b/tests/e2e/source.spec.ts index c6ec2be..abb263e 100644 --- a/tests/e2e/source.spec.ts +++ b/tests/e2e/source.spec.ts @@ -324,7 +324,7 @@ test('stack history lists commits, opens detail, and handles empty/load-more', a }); await page.goto(`${stackUrl()}/history`); await expect(page.getByRole('heading', { name: 'No commits for this stack' })).toBeVisible(); - await expect(page.getByText('No commits touching this stack were found.')).toBeVisible(); + await expect(page.getByText(/No commits touching this stack were found/)).toBeVisible(); await expectAccessible(page); }); diff --git a/tests/integration/git-workflow-journey.test.ts b/tests/integration/git-workflow-journey.test.ts new file mode 100644 index 0000000..d7c87cd --- /dev/null +++ b/tests/integration/git-workflow-journey.test.ts @@ -0,0 +1,214 @@ +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as commitRoute from '@/app/api/workspaces/[workspaceId]/repositories/[repositoryId]/git/commit/route'; +import * as pushRoute from '@/app/api/workspaces/[workspaceId]/repositories/[repositoryId]/git/push/route'; +import * as stackHistory from '@/app/api/workspaces/[workspaceId]/stacks/[stackId]/history/route'; +import * as stackDetail from '@/app/api/workspaces/[workspaceId]/stacks/[stackId]/history/[sha]/route'; +import * as setup from '@/app/api/setup/route'; +import { GitCli } from '@/server/providers/git/git-cli'; +import type { HistoryDetail, HistoryPage } from '@/shared/git-history'; +import { createHarness, type TestHarness } from '../support/container'; +import { gitServerOverrides, startGitServer, type GitServer } from '../support/git-server'; +import { call, cookieFrom } from '../support/http'; + +/** + * End-to-end journeys across the v0.5.0 boundaries (drafts → workflow → Git → fetch → history → audit). + * Per-outcome workflow cases live in git-workflow.test.ts; this file only covers what crosses services. + */ +const REPO = 'acme/journey.git'; +const REMOTE = `https://git.test/${REPO}`; +const TOKEN = 'journey-test-' + '0123456789abcdef'; +const WRONG_TOKEN = 'journey-wrong-' + '0123456789abcdef'; +const AUTHOR = { name: 'Stack Operator', email: 'operator@example.invalid' }; +const COMPOSE = 'services:\n app:\n image: nginx:1.27\n'; +let server: GitServer; +let h: TestHarness; +let cookie: string; +let workspaceId: string; +let repositoryId: string; +let actorUserId: string; +let wikiId: string; +let notesId: string; + +beforeEach(async () => { + server = await startGitServer({ token: TOKEN }); + server.createRepo(REPO); + server.commitFiles(REPO, 'main', { + 'apps/wiki/compose.yaml': COMPOSE, + 'apps/notes/compose.yaml': COMPOSE, + }); + h = await createHarness({ overrides: gitServerOverrides(server) }); + const admin = await call(setup.POST, { + method: 'POST', + body: { username: 'admin', password: 'correct horse battery staple' }, + }); + cookie = cookieFrom(admin); + actorUserId = admin.json.user.id; + await h.container.auth.updateGitIdentity(actorUserId, AUTHOR); + workspaceId = (await h.container.workspaces.create({ name: 'Homelab' }, actorUserId)).id; + repositoryId = ( + await h.container.repositories.create( + workspaceId, + { + gitProviderType: 'gitea', + remoteUrl: REMOTE, + autoAddStacks: false, + auth: { type: 'token', token: TOKEN }, + }, + actorUserId, + ) + ).id; + await h.container.repositories.sync(repositoryId); + const stack = (rootPath: string) => + h.container.stacks.create( + workspaceId, + repositoryId, + { rootPath, composePath: `${rootPath}/compose.yaml` }, + actorUserId, + ); + wikiId = (await stack('apps/wiki')).id; + notesId = (await stack('apps/notes')).id; +}); + +afterEach(async () => { + await h.cleanup(); + await server.close(); +}); + +const params = () => ({ workspaceId, repositoryId }); +const commit = (body: Record) => + call(commitRoute.POST, { method: 'POST', cookie, params: params(), body }); +const push = (commitSha: string, expectedRemoteSha: string) => + call(pushRoute.POST, { method: 'POST', cookie, params: params(), body: { commitSha, expectedRemoteSha } }); +const history = async (stackId: string) => + (await call(stackHistory.GET, { cookie, params: { workspaceId, stackId }, path: '/history' })) + .json as HistoryPage; +const drafts = () => h.container.repos.drafts.list(repositoryId); +async function save(file: string, content: string) { + const view = await h.container.source.readFile(workspaceId, repositoryId, '', file); + return h.container.source.saveDraft( + workspaceId, + repositoryId, + '', + { path: file, content, baseBlobSha: view.blobSha }, + actorUserId, + ); +} +async function git(args: string[]) { + const overrides = gitServerOverrides(server); + const cli = new GitCli({ + homeDir: path.join(h.dataDir, 'git-home'), + allowedProtocols: overrides.gitAllowedProtocols, + extraConfig: overrides.gitExtraConfig, + }); + const source = (await h.container.repositories.localSource(workspaceId, repositoryId))!; + return (await cli.run(args, { cwd: source.cloneDir })).stdout; +} + +describe('Git workflow journeys', () => { + it('draft → commit → push → fetch shows the commit in the stack history it touched, and only there', async () => { + const edited = COMPOSE.replace('nginx:1.27', 'nginx:1.28'); + await save('apps/wiki/compose.yaml', edited); + const committed = await commit({ paths: ['apps/wiki/compose.yaml'], message: 'Bump wiki\n\nWhy: patch' }); + expect(committed.json.status).toBe('commit_succeeded'); + const sha: string = committed.json.commitSha; + + // An unpushed commit is not history yet: history reads the last fetched snapshot only. + expect((await history(wikiId)).commits.map((c) => c.sha)).not.toContain(sha); + + expect((await push(sha, committed.json.expectedRemoteSha)).json.status).toBe('push_succeeded'); + expect((await history(wikiId)).commits.map((c) => c.sha)).not.toContain(sha); + await h.container.repositories.sync(repositoryId); + + const wiki = await history(wikiId); + expect(wiki.headSha).toBe(sha); + expect(wiki.commits[0]).toMatchObject({ + sha, + subject: 'Bump wiki', + author: AUTHOR, + files: [{ path: 'apps/wiki/compose.yaml', status: 'modified' }], + }); + expect((await history(notesId)).commits.map((c) => c.sha)).not.toContain(sha); + + const detail = (await call(stackDetail.GET, { cookie, params: { workspaceId, stackId: wikiId, sha } })) + .json as HistoryDetail; + expect(detail.commit.files[0]!.hunks![0]!.lines).toEqual( + expect.arrayContaining(['- image: nginx:1.27', '+ image: nginx:1.28']), + ); + + // Audit links the commit to the registered stack it touched, without contents or the message. + const audit = (await h.container.audit.list({ workspaceId })).filter((a) => a.action.startsWith('git.')); + expect(audit.find((a) => a.action === 'git.commit')).toMatchObject({ + outcome: 'success', + meta: { commitSha: sha, branch: 'main', changedFileCount: 1, stackIds: [wikiId] }, + }); + expect(audit.find((a) => a.action === 'git.push')).toMatchObject({ outcome: 'success' }); + expect(JSON.stringify(audit)).not.toContain('nginx:1.28'); + expect(JSON.stringify(audit)).not.toContain('Bump wiki'); + expect(JSON.stringify(audit)).not.toContain(AUTHOR.email); + + // Drafts are kept after a successful push; once fetched, the committed draft can be reviewed/discarded. + expect((await drafts()).map((d) => d.path)).toEqual(['apps/wiki/compose.yaml']); + }); + + it('a push refused for bad credentials keeps the commit and drafts, leaks no token, and retries', async () => { + await save('apps/notes/compose.yaml', COMPOSE + '# notes\n'); + const committed = await commit({ paths: ['apps/notes/compose.yaml'], message: 'Notes' }); + const sha: string = committed.json.commitSha; + const before = await drafts(); + + const repo = await h.container.repos.gitRepositories.findById(workspaceId, repositoryId); + await h.container.credentials.replaceSecret(workspaceId, repo!.credentialId!, WRONG_TOKEN, actorUserId); + const failed = await push(sha, committed.json.expectedRemoteSha); + expect(failed.status).toBe(502); + expect(failed.json).toMatchObject({ + status: 'git_operation_failed', + operation: 'push', + reason: 'auth', + commitSha: sha, + draftsPreserved: true, + }); + expect(await drafts()).toEqual(before); + expect(await git(['rev-parse', 'refs/stack-manager/heads/main'])).toBe(sha + '\n'); + const surfaced = + failed.text + h.logs.join('\n') + JSON.stringify(await h.container.audit.list({ workspaceId })); + for (const secret of [TOKEN, WRONG_TOKEN]) expect(surfaced).not.toContain(secret); + // The Git failure itself carries no remote URL or provider output either. + const gitAudit = (await h.container.audit.list({ workspaceId })).filter((a) => + a.action.startsWith('git.'), + ); + expect(failed.text + JSON.stringify(gitAudit)).not.toContain('git.test'); + expect( + (await h.container.audit.list({ workspaceId })).find((a) => a.action === 'git.push_rejected'), + ).toMatchObject({ outcome: 'failure', meta: { gitReason: 'auth', commitSha: sha } }); + + await h.container.credentials.replaceSecret(workspaceId, repo!.credentialId!, TOKEN, actorUserId); + expect((await push(sha, committed.json.expectedRemoteSha)).json.status).toBe('push_succeeded'); + }); + + it('documented recovery: removing a stranded unpushed commit lets drafts be committed on the new remote', async () => { + await save('apps/wiki/compose.yaml', COMPOSE + '# wiki\n'); + const stranded = await commit({ paths: ['apps/wiki/compose.yaml'], message: 'Wiki' }); + const remote = server.commit(REPO, 'main', 'README.md', '# Changed elsewhere\n'); + const blocked = await push(stranded.json.commitSha, stranded.json.expectedRemoteSha); + expect(blocked.json).toMatchObject({ status: 'remote_changed', state: { ahead: 1, behind: 1 } }); + await h.container.repositories.sync(repositoryId); + // Still blocked after a fetch: Stack Manager never merges, rebases or drops the unpushed commit itself. + await save('apps/notes/compose.yaml', COMPOSE + '# notes\n'); + expect((await commit({ paths: ['apps/notes/compose.yaml'], message: 'Notes' })).json.status).toBe( + 'remote_changed', + ); + + // GIT_WORKFLOW.md: `git update-ref -d refs/stack-manager/heads/` in the repository's clone. + // The drafts were kept, so the stranded change can be committed again on top of the new remote. + await git(['update-ref', '-d', 'refs/stack-manager/heads/main']); + const next = await commit({ + paths: ['apps/wiki/compose.yaml', 'apps/notes/compose.yaml'], + message: 'Wiki and notes', + push: true, + }); + expect(next.json.status).toBe('push_succeeded'); + expect(await git(['rev-parse', `${next.json.commitSha}^`])).toBe(remote + '\n'); + expect(await drafts()).toHaveLength(2); + }); +}); From eda09c15330a49f1a797baa55d6d3cb38711fbf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 12:27:52 +0000 Subject: [PATCH 2/4] docs: clarify draft lifetime, unpushed-commit recovery and History scope Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018E16JisaGNSCUUfrJpDmuZ --- docs/getting-started.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index aed2057..34824c8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -89,7 +89,8 @@ can't be opened or edited. ### Drafts -Drafts are stored in stack-manager's database and stay on the server until you commit them. If the file changes in +Drafts are stored in stack-manager's database and stay on the server until you discard them, even after they are +committed and pushed. Once you've checked the pushed result, discard the drafts you no longer need. If the file changes in Git after you start a draft, the editor marks it **Changed upstream** and it can't be committed. That way you can't overwrite someone else's change by accident. Saving again doesn't fix this: copy what you need, discard the draft and make the edit again on the current version. @@ -109,8 +110,10 @@ make the edit again on the current version. Stack Manager pushes only to the repository's tracked branch and **never force pushes**. If someone else pushed first, you'll see *"The remote branch changed since your copy was last updated."* Nothing is overwritten, and your -drafts and any local commit are kept. Fetch, review, and commit again. A failed or refused push never discards your -work either. See [Git workflow](GIT_WORKFLOW.md) for what each message means and how to recover. +drafts and any local commit are kept. If you hadn't committed yet, fetch, review your drafts and commit again. If +you already have a local commit that wasn't pushed, follow +[Recovering an unpushed local commit](GIT_WORKFLOW.md#recovering-an-unpushed-local-commit) instead. A failed or +refused push never discards your work either. See [Git workflow](GIT_WORKFLOW.md) for what each message means. ## 6. Files outside stacks @@ -126,6 +129,6 @@ whole repository. The repository page has tabs for them: ## 7. Keep it up to date -Use **Fetch now** on the repository page to pull the latest commits from your Git server. The editor, docs, -environment and history views always read from the fetched commit, plus your drafts. Fetching never changes or -deletes drafts. +Use **Fetch now** on the repository page to pull the latest commits from your Git server. The editor, docs and +environment views read from the fetched commit plus your drafts. History lists only the commits in the fetched +repository; drafts and unpushed commits don't appear there. Fetching never changes or deletes drafts. From 5439929da1db9832671f387e8e48b8c7cfb343a7 Mon Sep 17 00:00:00 2001 From: Uniskela <104075208+uniskela@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:17:49 +0000 Subject: [PATCH 3/4] test: assert the manual-recovery guidance in the remote-changed alert Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018E16JisaGNSCUUfrJpDmuZ --- tests/e2e/changes-commit.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/changes-commit.spec.ts b/tests/e2e/changes-commit.spec.ts index eff40a7..fef76af 100644 --- a/tests/e2e/changes-commit.spec.ts +++ b/tests/e2e/changes-commit.spec.ts @@ -212,6 +212,9 @@ test('remote-changed push keeps work and offers fetch', async ({ page }) => { await expect(page.getByText(/drafts are still safe/i)).toBeVisible(); await expect(page.getByText(/never force pushes/i)).toBeVisible(); await expect(page.getByText(/aaaaaaa was not pushed/)).toBeVisible(); + await expect( + page.getByText(/can't merge or rebase yet, so this work needs to be brought across by hand/i), + ).toBeVisible(); await expect(page.getByRole('link', { name: 'Open repository to fetch' })).toBeVisible(); await expectWorkPreserved(page); await expect(page).toHaveTitle(/Changes/); @@ -335,4 +338,5 @@ test('mobile: changes commit layout and accessibility', async ({ page }) => { await expectWorkPreserved(page); await expect(page).toHaveTitle(/Changes/); await expectAccessible(page); -}); +} +); From 543b3954b3138ef57dae1b579e75e42015a1d412 Mon Sep 17 00:00:00 2001 From: Uniskela <104075208+uniskela@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:18:46 +0000 Subject: [PATCH 4/4] test: restore Prettier formatting of the Changes e2e spec Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018E16JisaGNSCUUfrJpDmuZ --- tests/e2e/changes-commit.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e/changes-commit.spec.ts b/tests/e2e/changes-commit.spec.ts index fef76af..0c5d066 100644 --- a/tests/e2e/changes-commit.spec.ts +++ b/tests/e2e/changes-commit.spec.ts @@ -338,5 +338,4 @@ test('mobile: changes commit layout and accessibility', async ({ page }) => { await expectWorkPreserved(page); await expect(page).toHaveTitle(/Changes/); await expectAccessible(page); -} -); +});