{"record":{"id":"54cf82474c6b04bb","repo":"gitbutlerapp/gitbutler","slug":"a-committed-transaction-always-materializes-a-work","errorCode":null,"errorMessage":"a committed transaction always materializes a workspace","messagePattern":"a committed transaction always materializes a workspace","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/but-transaction/src/lib.rs","lineNumber":1247,"sourceCode":"    impl<T> Sealed for super::Commit<T> {}\n}\n\npub trait TransactionOutcome: sealed::Sealed {\n    type Outcome;\n\n    fn should_rollback(&self) -> bool;\n\n    /// Package the callback's value together with the workspace the transaction produced.\n    ///\n    /// `workspace` is `Some` exactly when [`Self::should_rollback`] returned `false`; a\n    /// rolled-back transaction is never materialized and so has no workspace to report.\n    fn into_outcome(self, workspace: Option<WorkspaceState>) -> Self::Outcome;\n}\n\n/// The workspace state that [`TransactionOutcome::into_outcome`] is handed whenever the\n/// transaction commits.\nfn committed_workspace(workspace: Option<WorkspaceState>) -> WorkspaceState {\n    workspace.expect(\"a committed transaction always materializes a workspace\")\n}\n\nimpl TransactionOutcome for () {\n    type Outcome = WorkspaceState;\n\n    fn should_rollback(&self) -> bool {\n        false\n    }\n\n    fn into_outcome(self, workspace: Option<WorkspaceState>) -> Self::Outcome {\n        committed_workspace(workspace)\n    }\n}\n\n/// Statically roll back the current transaction.\n#[must_use = \"`Rollback` must be returned from `with_transaction` for the transaction to be rolled back\"]\npub struct Rollback<T>(T);\n","sourceCodeStart":1229,"sourceCodeEnd":1265,"githubUrl":"https://github.com/gitbutlerapp/gitbutler/blob/caf1f223d3cfb94488c9198ad34487c6006c648f/crates/but-transaction/src/lib.rs#L1229-L1265","documentation":"Panic from `committed_workspace`'s `expect(\"a committed transaction always materializes a workspace\")` (but-transaction, lib.rs:1247). The `TransactionOutcome` trait contract says `into_outcome` receives `Some(workspace)` exactly when `should_rollback()` returned false. The panic fires when a value that claims not to roll back reaches the end of a commit path that produced no workspace — i.e. the contract between the outcome type and the commit machinery broke.","triggerScenarios":"Implementing `TransactionOutcome` for a custom type whose `should_rollback` returns false for a variant that actually represents an aborted/empty transaction; a library change where a commit path can now finish without materializing a workspace while outcomes still claim success; calling `into_outcome` manually with None.","commonSituations":"Downstream crates adding their own outcome wrappers around `with_transaction`; version skew between but-transaction and a fork that added new commit paths; unit tests that construct outcome values directly instead of going through the transaction.","solutions":["If you implement `TransactionOutcome`, make `should_rollback` return true for every variant that represents cancellation, abort, or an empty result","Don't call `into_outcome`/`committed_workspace` yourself; let `with_transaction` drive the lifecycle","If no custom outcome type is involved, capture the transaction inputs and report it as a but-transaction bug","Pin matching versions of but-transaction and but-workspace so commit paths and outcome handling agree"],"exampleFix":"// before\nimpl TransactionOutcome for MyOutcome {\n    fn should_rollback(&self) -> bool {\n        false // too broad: aborted runs also claim to commit\n    }\n    ...\n}\n\n// after\nimpl TransactionOutcome for MyOutcome {\n    fn should_rollback(&self) -> bool {\n        matches!(self, MyOutcome::Aborted | MyOutcome::Cancelled)\n    }\n    ...\n}","handlingStrategy":"validation","validationCode":"// If you implement TransactionOutcome, verify every variant's rollback answer:\nimpl TransactionOutcome for MyOutcome {\n    fn should_rollback(&self) -> bool {\n        matches!(self, MyOutcome::Aborted | MyOutcome::Cancelled /* every non-commit shape */)\n    }\n    fn into_outcome(self, ws: Option<WorkspaceState>) -> WorkspaceState {\n        debug_assert!(ws.is_some(), \"committed path must materialize a workspace\");\n        committed_workspace(ws)\n    }\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Let with_transaction drive into_outcome; never call it manually","Unit-test should_rollback for every variant of your outcome type","Keep but-transaction and but-workspace versions in lockstep"],"tags":["rust","panic","expect","trait-contract","transaction-outcome","workspace","gitbutler"],"backgroundTag":"trait-contract-violation-panic","analyzedSha":"caf1f223d3cfb94488c9198ad34487c6006c648f","analyzedAt":"2026-08-20T07:55:40.983Z","schemaVersion":2},"datasetVersion":"2026-08-31T19:17:28.585Z"}