{"record":{"id":"0ae134cb78e69a8f","repo":"gitbutlerapp/gitbutler","slug":"changes-being-non-empty-means-paths-are-non-empty","errorCode":null,"errorMessage":"changes being non-empty means paths are non-empty","messagePattern":"changes being non-empty means paths are non-empty","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/but/src/command/legacy/discard.rs","lineNumber":545,"sourceCode":"    },\n    Commits(NonEmpty<CommitId>),\n    CommittedFiles {\n        source: CommitId,\n        paths: NonEmpty<BString>,\n        changes: Vec<DiffSpec>,\n    },\n    Uncommitted {\n        paths: NonEmpty<BString>,\n        changes: Vec<DiffSpec>,\n    },\n}\n\nfn paths_from_changes(changes: &[DiffSpec]) -> NonEmpty<BString> {\n    let paths = changes\n        .iter()\n        .map(|change| change.path.clone())\n        .collect::<Vec<_>>();\n    NonEmpty::from_vec(paths).expect(\"changes being non-empty means paths are non-empty\")\n}\n","sourceCodeStart":527,"sourceCodeEnd":547,"githubUrl":"https://github.com/gitbutlerapp/gitbutler/blob/2497b8007aa4a1922dae9a805b32ffe5b5037785/crates/but/src/command/legacy/discard.rs#L527-L547","documentation":"Helper-contract panic in the discard command: paths_from_changes maps every DiffSpec to its path and re-wraps the result into NonEmpty. The function documents an implicit precondition - the changes slice must be non-empty - because an empty slice maps to zero paths and trips the expect. All current callers pass validated non-empty selections, so only a new caller passing a possibly-empty slice can trigger it.","triggerScenarios":"A future caller invokes paths_from_changes with an empty Vec<DiffSpec>, for example after filtering hunks/files to nothing, or with an empty uncommitted-changes selection that earlier validation failed to reject.","commonSituations":"Refactors where empty change selections become representable (all hunks deselected, empty staged set); new discard modes added without re-validating non-emptiness.","solutions":["Maintainer: change the signature to accept NonEmpty<DiffSpec> (or &NonEmpty<DiffSpec>) so emptiness is unrepresentable","Callers must early-return on empty selections before reaching this helper","Alternatively return Result and bubble a proper error for empty input"],"exampleFix":"// before\nfn paths_from_changes(changes: &[DiffSpec]) -> NonEmpty<BString> {\n    let paths = changes.iter().map(|c| c.path.clone()).collect::<Vec<_>>();\n    NonEmpty::from_vec(paths).expect(\"changes being non-empty means paths are non-empty\")\n}\n\n// after - make the precondition structural\nfn paths_from_changes(changes: &NonEmpty<DiffSpec>) -> NonEmpty<BString> {\n    let mut it = changes.iter().map(|c| c.path.clone());\n    let head = it.next().expect(\"iterator over NonEmpty yields one\");\n    NonEmpty { head, tail: it.collect() }\n}","handlingStrategy":"validation","validationCode":"// Caller-side: reject empty selections before invoking the helper\nlet changes = NonEmpty::from_vec(changes)\n    .ok_or_else(|| bad_input(\"select at least one change\").arg_name(\"<CHANGES>\"))?;\nlet paths = paths_from_changes(&changes);","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Make the helper take NonEmpty<DiffSpec> so the compiler enforces the precondition","Early-return a user-facing error when a selection filters down to zero changes","Never pass raw filter output into helpers documented to require non-empty input"],"tags":["rust","panic","nonempty","invariant","discard","diff-spec"],"backgroundTag":"nonempty-invariant-violation","analyzedSha":"2497b8007aa4a1922dae9a805b32ffe5b5037785","analyzedAt":"2026-08-17T00:30:25.648Z","schemaVersion":2},"datasetVersion":"2026-08-17T04:17:16.089Z"}