Skip to content

Commit 22304aa

Browse files
authored
Merge branch '06-03-scott-port-file-diff-contents-to-git2' into 06-03-scott-tag-commit-with-git2
2 parents bb35317 + 452ba67 commit 22304aa

14 files changed

Lines changed: 639 additions & 58 deletions

File tree

.github/workflows/danger.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,28 @@ jobs:
1111
contents: read
1212
pull-requests: write
1313
issues: write
14+
statuses: write
1415
steps:
1516
- name: Checkout repository
1617
uses: actions/checkout@v6
1718
with:
1819
fetch-depth: 0
1920
- name: Setup Bun
2021
uses: oven-sh/setup-bun@v2
22+
- name: Setup Node.js
23+
uses: actions/setup-node@v6
24+
with:
25+
node-version: 22
2126
- name: Install dependencies
2227
run: bun install --frozen-lockfile
2328
# Generate a coverage report so the dangerfile has something to surface.
2429
# `continue-on-error` keeps Danger running even if a test fails — Danger
2530
# itself is not the gate for test failures, the test workflow is.
2631
- name: Generate coverage (native)
2732
continue-on-error: true
28-
run: bunx vitest run --project=unit --coverage --coverage.reporter=json-summary --coverage.reporter=text --reporter=basic
33+
run: bunx vitest run --project=unit --coverage --coverage.reporter=json-summary --coverage.reporter=text
2934
working-directory: apps/native
3035
- name: Run Danger
31-
run: bunx danger ci
36+
run: npx danger ci
3237
env:
3338
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/storybook.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ jobs:
107107
--branch="$BRANCH" 2>&1 | tee /dev/stderr)
108108
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev' | head -n1 || true)
109109
if [ -z "$url" ]; then
110-
echo "::error::Cloudflare Pages deploy completed without a detectable preview URL"
110+
echo "::error::Cloudflare Pages deploy output did not include a detectable preview URL"
111111
exit 1
112112
fi
113113
echo "Storybook preview: $url"

apps/native/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
"@types/react-dom": "^19",
104104
"@vitejs/plugin-react": "^4.2.1",
105105
"@vitest/browser": "^3.0.0",
106-
"@vitest/coverage-v8": "^3.0.0",
106+
"@vitest/coverage-istanbul": "3.2.4",
107107
"@wdio/cli": "^9.27.0",
108108
"@wdio/globals": "^9.27.0",
109109
"@wdio/local-runner": "^9.27.0",
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
//! Native notifications for configuration drift detected by the watcher.
2+
3+
use std::sync::Mutex;
4+
5+
use crate::shared_types::GitStatus;
6+
7+
static LAST_DRIFT_NOTIFICATION_ID: Mutex<Option<String>> = Mutex::new(None);
8+
9+
#[cfg(target_os = "macos")]
10+
#[link(name = "UserNotifications", kind = "framework")]
11+
extern "C" {}
12+
13+
#[derive(Debug, Clone, PartialEq, Eq)]
14+
struct DriftNotification {
15+
id: String,
16+
title: &'static str,
17+
body: String,
18+
}
19+
20+
pub fn maybe_notify(git_status: Option<&GitStatus>, external_build_detected: bool) {
21+
let notification = notification_for_event(git_status, external_build_detected);
22+
let mut last_notification_id = match LAST_DRIFT_NOTIFICATION_ID.lock() {
23+
Ok(guard) => guard,
24+
Err(poisoned) => poisoned.into_inner(),
25+
};
26+
27+
let Some(notification) = notification else {
28+
*last_notification_id = None;
29+
return;
30+
};
31+
32+
if last_notification_id.as_deref() == Some(notification.id.as_str()) {
33+
return;
34+
}
35+
36+
// Don't let the one-shot external-build notification disrupt config-drift deduping.
37+
if notification.id != "external-build" {
38+
*last_notification_id = Some(notification.id.clone());
39+
}
40+
drop(last_notification_id);
41+
42+
if let Err(error) = send_native_notification(notification.title, &notification.body) {
43+
log::warn!("Failed to send drift notification: {error}");
44+
}
45+
}
46+
47+
fn notification_for_event(
48+
git_status: Option<&GitStatus>,
49+
external_build_detected: bool,
50+
) -> Option<DriftNotification> {
51+
if external_build_detected {
52+
return Some(DriftNotification {
53+
id: "external-build".to_string(),
54+
title: "nixmac detected drift",
55+
body: "A nix build was detected outside nixmac. Open nixmac to review and continue."
56+
.to_string(),
57+
});
58+
}
59+
60+
let status = git_status?;
61+
let file_count = status.files.len();
62+
if file_count == 0 {
63+
return None;
64+
}
65+
66+
let change_noun = if file_count == 1 { "change" } else { "changes" };
67+
Some(DriftNotification {
68+
id: format!(
69+
"config-drift:{}",
70+
status.head_commit_hash.as_deref().unwrap_or("no-head")
71+
),
72+
title: "nixmac detected config drift",
73+
body: format!(
74+
"{file_count} uncommitted {change_noun} in your nix config. Open nixmac to review, commit, or discard."
75+
),
76+
})
77+
}
78+
79+
#[cfg(target_os = "macos")]
80+
fn send_native_notification(title: &str, body: &str) -> Result<(), String> {
81+
use cocoa::base::{id, nil};
82+
use cocoa::foundation::{NSAutoreleasePool, NSString};
83+
use objc::{class, msg_send, sel, sel_impl};
84+
85+
unsafe {
86+
let pool = NSAutoreleasePool::new(nil);
87+
let content: id = msg_send![class!(UNMutableNotificationContent), new];
88+
let title = NSString::alloc(nil).init_str(title);
89+
let body = NSString::alloc(nil).init_str(body);
90+
let identifier =
91+
NSString::alloc(nil).init_str(&format!("nixmac-drift-{}", uuid::Uuid::new_v4()));
92+
93+
let _: () = msg_send![content, setTitle: title];
94+
let _: () = msg_send![content, setBody: body];
95+
96+
let request: id = msg_send![
97+
class!(UNNotificationRequest),
98+
requestWithIdentifier: identifier
99+
content: content
100+
trigger: nil
101+
];
102+
103+
let center: id = msg_send![class!(UNUserNotificationCenter), currentNotificationCenter];
104+
let _: () = msg_send![center, addNotificationRequest: request withCompletionHandler: nil];
105+
106+
let _: () = msg_send![title, release];
107+
let _: () = msg_send![body, release];
108+
let _: () = msg_send![identifier, release];
109+
let _: () = msg_send![content, release];
110+
let _: () = msg_send![pool, drain];
111+
}
112+
113+
Ok(())
114+
}
115+
116+
#[cfg(not(target_os = "macos"))]
117+
fn send_native_notification(_title: &str, _body: &str) -> Result<(), String> {
118+
Ok(())
119+
}
120+
121+
#[cfg(test)]
122+
mod tests {
123+
use super::*;
124+
use crate::shared_types::{ChangeType, GitFileStatus, GitStatus};
125+
126+
fn clean_status() -> GitStatus {
127+
GitStatus {
128+
files: Vec::new(),
129+
branch: Some("main".to_string()),
130+
diff: String::new(),
131+
additions: 0,
132+
deletions: 0,
133+
head_commit_hash: Some("abc123".to_string()),
134+
clean_head: true,
135+
changes: Vec::new(),
136+
}
137+
}
138+
139+
140+
#[test]
141+
fn no_notification_without_drift() {
142+
let status = clean_status();
143+
assert_eq!(notification_for_event(Some(&status), false), None);
144+
}
145+
146+
#[test]
147+
fn external_build_drift_takes_priority() {
148+
let status = clean_status();
149+
assert_eq!(
150+
notification_for_event(Some(&status), true),
151+
Some(DriftNotification {
152+
id: "external-build".to_string(),
153+
title: "nixmac detected drift",
154+
body:
155+
"A nix build was detected outside nixmac. Open nixmac to review and continue."
156+
.to_string(),
157+
})
158+
);
159+
}
160+
161+
#[test]
162+
fn uncommitted_config_drift_includes_file_count() {
163+
let mut status = clean_status();
164+
status.files = vec![GitFileStatus {
165+
path: "flake.nix".to_string(),
166+
change_type: ChangeType::Edited,
167+
}];
168+
status.diff = "diff --git a/flake.nix b/flake.nix".to_string();
169+
status.additions = 3;
170+
status.clean_head = false;
171+
172+
assert_eq!(
173+
notification_for_event(Some(&status), false),
174+
Some(DriftNotification {
175+
id: "config-drift:abc123".to_string(),
176+
title: "nixmac detected config drift",
177+
body: "1 uncommitted change in your nix config. Open nixmac to review, commit, or discard."
178+
.to_string(),
179+
})
180+
);
181+
}
182+
}

apps/native/src-tauri/src/state/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
2424
pub mod build_state;
2525
pub mod completion_log;
26+
pub mod drift_notifications;
2627
pub mod evolve_state;
2728
pub mod preferences;
2829
/// Generic state slices used by runtime state and scoped preferences.

apps/native/src-tauri/src/state/watcher.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! which is kept in sync by both this watcher and the evolution/summarize handlers.
66
77
use crate::shared_types::GitState;
8-
use crate::state::{build_state, evolve_state};
8+
use crate::state::{build_state, drift_notifications, evolve_state};
99
use crate::storage::store;
1010
use crate::{db, git, summarize};
1111
use std::sync::atomic::{AtomicBool, Ordering};
@@ -116,6 +116,8 @@ where
116116
if let Ok(es) = evolve_state::get(&app_handle) {
117117
let _ = evolve_state::set(&app_handle, es, &status.changes);
118118
}
119+
// Native drift notification (config drift / external build).
120+
drift_notifications::maybe_notify(Some(&status), external_build_detected);
119121
// One emit per slice — frontend listens on its dedicated channel.
120122
// fire-and-forget: window may not be connected yet.
121123
let _ = app_handle.emit(

0 commit comments

Comments
 (0)