Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/audits/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ Currently-actionable findings, pass-scoped. Earlier passes (1–3) are largely i
| P4-QUAL-02 | P1 | `feat/p4-qual-02-properties` (2026-07-04) | Property/model-check layer over the pure `Decide`/`Projection` seam (`P5-ARCH-01`→PR #87→here), on the test-only `pgregory.net/rapid` dep (zero transitive deps, adopted per this finding's explicit ask). Four rapid suites + a fuzz bridge: (1) `hlc_property_test.go` — Send/Receive interleavings under a backward-stepping INJECTED clock prove strict Send monotonicity, Receive non-regression, the EXACT `MaxSkew` accept/reject boundary, and logical-overflow carry; (2) `decide_rapid_test.go` — randomized convergence: two independent delivery permutations of a generated event set fold to one `Projection` + duplicate idempotency, wrapped as coverage-guided `FuzzDecideConvergence` (`go test -fuzz`; a 30s ubuntu CI step); (3) `import_replay_property_test.go` — `BuildSnapshot`→`ImportSnapshot` then subset-replay ≡ full replay (active rows); (4) `replica_model_test.go` — the audit's 3-replica model check: independent orders SPLIT into sequential `ApplyEvents` batches (cross-pull-window realism, where the pre-#87 divergence hid) converge byte-identically, with duplicate re-delivery (idempotency) and a tombstone-GC interleaving (active-rows-only invariant, since GC purges only deleted rows). The generator is confined to the provably-convergent class by TWO witness-pinned exclusions. **New finding surfaced here** (beyond P5-ARCH-01's documented delete residual): same-path/different-remote convergence is order-dependent **with NO delete involved** whenever one remote carries multiple events — same-remote LWW keeps that remote's HIGHEST HLC while cross-remote `reconcileSamePath` keeps the LOWEST coordinate, so the winner flips by delivery order. Both divergences share the lowest-coordinate reconcile root cause and are pinned by `TestDecideDifferentRemote{Delete,MultiEvent}DivergesWitness` tripwires that FAIL the day `reconcileSamePath` is made LWW-consistent, forcing the matching generator exclusion to be removed. **Follow-up SHIPPED 2026-07-04** (`fix/reconcile-same-path-hlc-monotonic`): the winner is HLC-monotonic (highest `(HLC, deviceID, eventID)`, consistent with same-remote LWW and `importEntryTx`); both tripwires fired as designed and were retired with their exclusions, and `genEventSet` now draws the full event space (deletes + multi-event remotes across different-remote paths). |
| P6-XP-03 | P2 | `fix/p6-xp-03` (2026-07-04) | `run-loop` now runs its advertised **scan** stage (chosen over the doc-only fix): `runLoopTick` prepends an idempotent scan+adopt before `runSyncCycle`, restoring the local→hub auto-path the daemonless loop otherwise lacked. Idempotency (the hazard: `adoptFindings` unconditionally emits `project.added` per finding) is handled by `findingAlreadyAdopted` — a finding is adopted only when `store.ProjectByPath` has no active row matching its type and (for `git_repo`) `remote_key`; `adoptNewFindings` filters then delegates to the existing `adoptFindings`, so one-shot `scan --adopt` is unchanged. Warning-class findings (secret-looking files, symlink escapes) and duplicate-remote findings are routed to stderr and NEVER auto-adopted (duplicates are dropped in the loop). The stderr tick header + `Short`/doc/README/spec-00 wording are reconciled to "scan + sync + materialize." Pinned by `TestRunLoopScanAdoptIdempotentAndPicksUpNewRepos` (4 ticks, no duplicate events + mid-run pickup), `...SkipsDuplicateRemotes`, `...WarnsSecretWithoutAdopting`, and an extended `run_loop_once.txtar` (`pushed 2 → pushed 0`). |
| P6-DATA-04 | P2 | `fix/p6-data-04` (2026-07-04) | `devstrap db backup --full <out.tar>` now captures a recoverable workspace: `state.db` (`VACUUM INTO`), the `blobs/<sha256>.age` files `AllBlobRefs` reports, the key material (`keys/` under file custody; a custody-aware keychain **escrow** — device age + Ed25519 signing identities + every held WCK epoch from `HeldKeys` + hub S3 creds via `HybridStore.ExportForBackup`, a hard error if any required key is unreadable), AND `config.yaml` (the hub pointer + custom root). New `devstrap db restore <in.tar>` refuses a non-empty state dir without `--force`, validates the staged `state.db` (`quick_check`+`foreign_key_check`) before promoting, and swaps ONLY the captured targets in place (temp+rename+rollback) so un-captured Home data (`quarantine/`, `logs/`) survives; a zip-slip guard (`sanitizeBackupEntry`) rejects abs/`..`/symlink/out-of-layout entries. New doctor **dangling-blob-refs** check + a keychain-custody restore warning; the `doctor` "restore from a db backup" remedy now points at `--full`. Round-trip test proves `env capture → backup --full → wipe → restore → env hydrate` recovers the identical secret AND config `hub`/`root` survive; blocking review finding (config.yaml omission / whole-Home wipe) fixed. spec/12 disaster-recovery runbook. |
| P4-GIT-04 | P2 | `feat/p4-git-04-worktree-gc` (2026-07-06) | `worktree cleanup --merged` now also reaps squash/rebase-merged worktrees: new `git.Runner.IsSquashMerged` — offline content-equivalence via the current-tree merge probe (`git merge-tree --write-tree <base> <branch>`, git ≥ 2.38: merged iff the simulated merge's tree equals the CURRENT base tree). The first-draft `git cherry`/patch-id chain was replaced in dual review — historical patch-id equivalence matches merged-then-REVERTED changes and would reap unmerged work (both reviewers reproduced it). Conservative rule: conflicting merge / old git / any error = not merged. Documented accepted limitation: a coincidentally-identical unrelated commit reads as merged (tripwire-pinned); every reap prints the deleted branch's tip SHA as the recovery breadcrumb. Ancestry stays the first check; misses consult content-equivalence, labeled `merged (squash)`; base fetched under the repo lock, deduped per project/ref; reaped branches get `git branch -D` (warn-only). Forge-API (`gh pr list`) cross-checks are an explicit non-goal — cleanup works offline. Pinned by the real-git `TestIsSquashMerged*` matrix (incl. revert-on-base) + CLI e2e `TestWorktreeCleanupReapsSquashMergedWorktree`. |

### Pass 6 (2026-07-01) — 0 open of 43 — **PASS CLOSED**; **all five P1s shipped**; **quick-win, sync-convergence, and snapshot-exchange waves complete**

Expand Down Expand Up @@ -161,7 +162,7 @@ Currently-actionable findings, pass-scoped. Earlier passes (1–3) are largely i
| P4-HUB-15 | P2 | Cost controls, quotas, rate limiting |
| P4-HUB-16 | P2 | At-rest versioning/Object-Lock + backup/replication runbook |
| P4-GIT-03 | — | **Shipped 2026-07-05** — OS-enforced agent sandbox complete: macOS Seatbelt + Linux bubblewrap + Linux Landlock fallback (write confinement, credential denies, policy-scoped network deny), the Linux seccomp syscall denylist, unsigned local `sandbox.violation` telemetry, and tighter read confinement (`--read-confine`, default-on for `readonly`) — moved to _Recently shipped_ above. The three named remaining items (seccomp, `sandbox.violation` telemetry, tighter read confinement) are all shipped; containerization stays a separate spec/14 roadmap direction, not a `P4-GIT-03` remainder. |
| P4-GIT-04 | P2 | Worktree GC that reaps squash/rebase-merged worktrees |
| P4-GIT-04 | | **Shipped 2026-07-06** (`feat/p4-git-04-worktree-gc`) — moved to _Recently shipped_ above. |
| P4-GIT-07 | P3 | Persisted per-project materialize failure record, resume, progress detail |
| P4-QUAL-04 | P2 | Enforce coverage in CI + add a Windows build _(narrowed 2026-07-05: the coverage half shipped — ci.yml generates `coverage.out` and gates a 50% `go tool cover -func` floor, closing `P5-QUAL-04`; remaining open scope is the Windows build only — `internal/platform` build tags are already windows-clean, known runtime risk: the `syscall.Signal(0)` liveness probe in `internal/cli/repo_lock.go`)_ |
| P4-QUAL-05 | — | **Shipped v0.1.1 (2026-07-05)** — moved to _Recently shipped_ above. |
Expand Down
48 changes: 46 additions & 2 deletions internal/cli/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ func newWorktreeCleanupCommand(stdout io.Writer, opts *options) *cobra.Command {
}
removed := 0
skipped := 0
// One base fetch per (project, base ref) per run — N worktrees on
// the same project must not trigger N redundant network fetches
// (review finding).
refreshed := map[string]bool{}
for _, wt := range worktrees {
r := gitRunner(opts)
project, err := store.ProjectByID(cmd.Context(), wt.NamespaceID)
Expand Down Expand Up @@ -511,19 +515,46 @@ func newWorktreeCleanupCommand(stdout io.Writer, opts *options) *cobra.Command {
skipped++
continue
}
if refreshKey := project.ID + "\x00" + wt.BaseRef; !refreshed[refreshKey] {
refreshed[refreshKey] = true
if err := refreshWorktreeBase(cmd.Context(), opts, r, project, repoPath, wt.BaseRef); err != nil {
_, _ = fmt.Fprintf(stdout, "warning: could not refresh %s for worktree %s: %v; using local ref\n", wt.BaseRef, wt.ID, err)
}
}
mergeLabel := "merged"
mergedOut, err := r.Run(cmd.Context(), wt.Path, "branch", "--merged", wt.BaseRef, "--list", wt.Branch)
if err != nil || !strings.Contains(mergedOut, wt.Branch) {
skipped++
continue
squashMerged, squashErr := r.IsSquashMerged(cmd.Context(), wt.Path, wt.Branch, wt.BaseRef)
if squashErr != nil || !squashMerged {
skipped++
continue
}
mergeLabel = "merged (squash)"
}
// Recovery breadcrumb: content-equivalence can match a
// coincidentally-identical unrelated commit (documented
// limitation), so name the deleted branch's tip — recreating
// it is one `git branch <name> <sha>` away until git gc.
tip := ""
if out, terr := r.RevParse(cmd.Context(), repoPath, wt.Branch); terr == nil {
tip = strings.TrimSpace(out)
}
if err := r.WorktreeRemove(cmd.Context(), repoPath, wt.Path, force); err != nil {
skipped++
continue
}
if _, err := r.Run(cmd.Context(), repoPath, "branch", "-D", wt.Branch); err != nil {
_, _ = fmt.Fprintf(stdout, "warning: failed to delete branch %s for removed worktree %s: %v\n", wt.Branch, wt.ID, err)
}
if err := store.MarkWorktreeRemoved(cmd.Context(), wt.ID); err != nil {
return err
}
removed++
if tip != "" {
_, _ = fmt.Fprintf(stdout, "Removed worktree %s (%s; branch %s was at %s)\n", wt.ID, mergeLabel, wt.Branch, tip)
} else {
_, _ = fmt.Fprintf(stdout, "Removed worktree %s (%s)\n", wt.ID, mergeLabel)
}
}
_, err = fmt.Fprintf(stdout, "Cleaned up %d worktrees (%d skipped)\n", removed, skipped)
return err
Expand All @@ -534,6 +565,19 @@ func newWorktreeCleanupCommand(stdout io.Writer, opts *options) *cobra.Command {
return cmd
}

func refreshWorktreeBase(ctx context.Context, opts *options, r dsgit.Runner, project state.ProjectStatus, repoPath, baseRef string) error {
remote, branch, ok := strings.Cut(baseRef, "/")
if !ok || remote == "" || branch == "" {
return fmt.Errorf("base ref must be remote/branch, got %q", baseRef)
}
unlock, err := acquireRepoLock(opts.paths().Home, project.ID)
if err != nil {
return err
}
defer unlock()
return r.Fetch(ctx, repoPath, remote, branch)
}

var slugPattern = regexp.MustCompile(`[^a-z0-9]+`)

const worktreeBranchAttempts = 3
Expand Down
121 changes: 121 additions & 0 deletions internal/cli/worktree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/url"
Expand Down Expand Up @@ -108,6 +109,89 @@ func TestCreateFreshWorktreeCleansUpAfterInsertWorktreeFailure(t *testing.T) {
assertNoAgentBranches(t, localPath)
}

func TestWorktreeCleanupReapsSquashMergedWorktree(t *testing.T) {
home := filepath.Join(t.TempDir(), ".devstrap")
root := filepath.Join(t.TempDir(), "Code")
localPath := setupFreshWorktreeRepo(t, home, root, "auto", false)
// Repo-local identity is shared by the clone's worktrees; CI runners have
// no global git config, so the worktree commits below need it.
runGit(t, localPath, "config", "user.email", "devstrap@example.test")
runGit(t, localPath, "config", "user.name", "DevStrap Test")

stdout, stderr, err := executeForTest("--home", home, "--root", root, "worktree", "new", "work/acme/repo", "--fresh-upstream", "--name", "squash merged")
if err != nil {
t.Fatalf("worktree new squash stdout = %q stderr = %q err = %v", stdout, stderr, err)
}
stdout, stderr, err = executeForTest("--home", home, "--root", root, "worktree", "new", "work/acme/repo", "--fresh-upstream", "--name", "unmerged")
if err != nil {
t.Fatalf("worktree new unmerged stdout = %q stderr = %q err = %v", stdout, stderr, err)
}
worktrees := listWorktreesForTest(t, home, root)
if len(worktrees) != 2 {
t.Fatalf("worktree count = %d, want 2: %+v", len(worktrees), worktrees)
}
var squashWT, unmergedWT testWorktree
for _, wt := range worktrees {
switch {
case strings.Contains(wt.Branch, "squash-merged"):
squashWT = wt
case strings.Contains(wt.Branch, "unmerged"):
unmergedWT = wt
}
}
if squashWT.ID == "" || unmergedWT.ID == "" {
t.Fatalf("did not find expected worktrees: %+v", worktrees)
}

if err := os.WriteFile(filepath.Join(squashWT.Path, "squashed.txt"), []byte("squashed\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, squashWT.Path, "add", "squashed.txt")
runGit(t, squashWT.Path, "commit", "-m", "squashed change")
runGit(t, localPath, "push", "origin", squashWT.Branch)

if err := os.WriteFile(filepath.Join(unmergedWT.Path, "unmerged.txt"), []byte("unmerged\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, unmergedWT.Path, "add", "unmerged.txt")
runGit(t, unmergedWT.Path, "commit", "-m", "unmerged change")

tmp := t.TempDir()
remote := strings.TrimSpace(runGitOutput(t, localPath, "remote", "get-url", "origin"))
integrator := filepath.Join(tmp, "integrator")
runGit(t, tmp, "clone", remote, integrator)
runGit(t, integrator, "config", "user.email", "devstrap@example.test")
runGit(t, integrator, "config", "user.name", "DevStrap Test")
runGit(t, integrator, "checkout", "main")
runGit(t, integrator, "fetch", "origin", squashWT.Branch)
runGit(t, integrator, "merge", "--squash", "origin/"+squashWT.Branch)
runGit(t, integrator, "commit", "-m", "squash merge worktree")
runGit(t, integrator, "push", "origin", "main")

stdout, stderr, err = executeForTest("--home", home, "--root", root, "worktree", "cleanup", "--merged")
if err != nil {
t.Fatalf("cleanup stdout = %q stderr = %q err = %v", stdout, stderr, err)
}
if !strings.Contains(stdout, "merged (squash)") {
t.Fatalf("cleanup stdout = %q, want squash merge label", stdout)
}
if !strings.Contains(stdout, "Cleaned up 1 worktrees (1 skipped)") {
t.Fatalf("cleanup stdout = %q, want one removed and one skipped", stdout)
}
if got := worktreeStatusForTest(t, filepath.Join(home, "state.db"), squashWT.ID); got != "removed" {
t.Fatalf("squash worktree status = %q, want removed", got)
}
if got := worktreeStatusForTest(t, filepath.Join(home, "state.db"), unmergedWT.ID); got != "active" {
t.Fatalf("unmerged worktree status = %q, want active", got)
}
if branches := runGitOutput(t, localPath, "branch", "--list", squashWT.Branch); strings.TrimSpace(branches) != "" {
t.Fatalf("squash branch still exists: %q", branches)
}
if branches := runGitOutput(t, localPath, "branch", "--list", unmergedWT.Branch); !strings.Contains(branches, unmergedWT.Branch) {
t.Fatalf("unmerged branch missing: %q", branches)
}
}

func TestWorktreeUnlockClearsStaleAndRefusesLiveLock(t *testing.T) {
ctx := context.Background()
home := filepath.Join(t.TempDir(), ".devstrap")
Expand Down Expand Up @@ -167,6 +251,43 @@ func TestWorktreeUnlockClearsStaleAndRefusesLiveLock(t *testing.T) {
}
}

type testWorktree struct {
ID string `json:"id"`
Path string `json:"path"`
Branch string `json:"branch"`
}

func listWorktreesForTest(t *testing.T, home, root string) []testWorktree {
t.Helper()
stdout, stderr, err := executeForTest("--home", home, "--root", root, "worktree", "list", "--json")
if err != nil {
t.Fatalf("worktree list stdout = %q stderr = %q err = %v", stdout, stderr, err)
}
var worktrees []testWorktree
if err := json.Unmarshal([]byte(stdout), &worktrees); err != nil {
t.Fatalf("decode worktree list: %v\n%s", err, stdout)
}
return worktrees
}

func worktreeStatusForTest(t *testing.T, dbPath, wtID string) string {
t.Helper()
q := url.Values{}
q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "foreign_keys(1)")
dsn := (&url.URL{Scheme: "file", Path: dbPath, RawQuery: q.Encode()}).String()
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
var status string
if err := db.QueryRow("SELECT status FROM worktrees WHERE id = ?", wtID).Scan(&status); err != nil {
t.Fatal(err)
}
return status
}

func itoa(n int) string {
return strconv.Itoa(n)
}
Expand Down
42 changes: 42 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,48 @@ func (r Runner) RevParse(ctx context.Context, dir, ref string) (string, error) {
return r.Run(ctx, dir, "rev-parse", ref)
}

// IsSquashMerged reports whether branch's content is already contained in the
// CURRENT baseRef tree — the content-equivalence test behind `worktree cleanup
// --merged`'s squash/rebase detection (P4-GIT-04). It simulates the merge
// (`git merge-tree --write-tree <baseRef> <branch>`, git >= 2.38): when the
// resulting tree is identical to baseRef's own tree, merging the branch would
// contribute nothing — every change the branch carries is already present in
// base, which is exactly the effect of a squash- or rebase-merge. Comparing
// against the CURRENT tree (rather than patch-id history) means a change that
// was merged and then REVERTED on base correctly reads as NOT merged
// (dual-review finding: historical patch-id equivalence would have deleted
// genuinely-unmerged work).
//
// Conservative by construction: a conflicting simulated merge (content
// diverged), an invalid ref, or an older git without --write-tree all report
// false — doubt is never grounds to reap.
//
// Documented accepted limitation (inherent to ANY content-equivalence test):
// a branch whose net change ALSO landed via an unrelated identical commit is
// indistinguishable from a squash-merge and reads as merged; the reap
// breadcrumb (the printed branch tip SHA) is the recovery path. Pinned by
// TestIsSquashMergedMatchesCoincidentallyIdenticalDiff.
func (r Runner) IsSquashMerged(ctx context.Context, dir, branch, baseRef string) (bool, error) {
if !safeBranchName(branch) {
return false, fmt.Errorf("invalid git branch name %q", branch)
}
if !safeBranchName(baseRef) {
return false, fmt.Errorf("invalid git base ref %q", baseRef)
}
merged, err := r.Run(ctx, dir, "merge-tree", "--write-tree", baseRef, branch)
if err != nil {
// Exit 1 is a conflicting simulated merge — the canonical not-merged
// answer. Any other failure (old git, bad ref) is equally
// conservative: never reap on doubt.
return false, nil
}
baseTree, err := r.Run(ctx, dir, "rev-parse", baseRef+"^{tree}")
if err != nil {
return false, err
}
return strings.TrimSpace(merged) == strings.TrimSpace(baseTree), nil
}

func (r Runner) WorktreeAdd(ctx context.Context, dir, path, branch, base string) error {
if !safeBranchName(branch) {
return fmt.Errorf("invalid git branch name %q", branch)
Expand Down
Loading
Loading