prodigy 0.4.4

Turn ad-hoc Claude sessions into reproducible development pipelines with parallel AI agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//! Thread-safe git operations
//!
//! This module provides synchronized access to git operations to prevent
//! race conditions when multiple processes might be modifying the repository.
//!
//! This module now acts as a compatibility layer, delegating to the trait-based
//! abstraction for better testability while maintaining the existing API.

use crate::abstractions::{GitOperations, RealGitOperations};
use anyhow::Result;
use once_cell::sync::Lazy;
use std::sync::Arc;
use tokio::sync::Mutex;

/// Global singleton for git operations
static GIT_OPS: Lazy<Arc<Mutex<RealGitOperations>>> =
    Lazy::new(|| Arc::new(Mutex::new(RealGitOperations::new())));

/// Execute a git command with exclusive access
///
/// # Arguments
/// * `args` - Arguments to pass to the git command
/// * `description` - Human-readable description of the operation
///
/// # Returns
/// The command output on success, or an error with context
pub async fn git_command(args: &[&str], description: &str) -> Result<std::process::Output> {
    let ops = GIT_OPS.lock().await;
    ops.git_command(args, description).await
}

/// Get the last commit message
///
/// Thread-safe wrapper for getting the most recent commit message.
pub async fn get_last_commit_message() -> Result<String> {
    let ops = GIT_OPS.lock().await;
    ops.get_last_commit_message().await
}

/// Check git status
///
/// Thread-safe wrapper for checking repository status.
pub async fn check_git_status() -> Result<String> {
    let ops = GIT_OPS.lock().await;
    ops.check_git_status().await
}

/// Stage all changes
///
/// Thread-safe wrapper for staging all modifications.
pub async fn stage_all_changes() -> Result<()> {
    let ops = GIT_OPS.lock().await;
    ops.stage_all_changes().await
}

/// Create a commit
///
/// Thread-safe wrapper for creating a commit with the given message.
///
/// # Arguments
/// * `message` - The commit message
pub async fn create_commit(message: &str) -> Result<()> {
    let ops = GIT_OPS.lock().await;
    ops.create_commit(message).await
}

/// Check if we're in a git repository
///
/// # Returns
/// true if the current directory is inside a git repository
pub async fn is_git_repo() -> bool {
    let ops = GIT_OPS.lock().await;
    ops.is_git_repo().await
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};
    use tempfile::TempDir;
    use tokio::process::Command;

    /// Test helper: Execute git command in a specific directory
    async fn git_in_dir(dir: &std::path::Path, args: &[&str]) -> Result<std::process::Output> {
        let output = Command::new("git")
            .args(args)
            .current_dir(dir)
            .output()
            .await?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(anyhow::anyhow!("Git command failed: {}", stderr));
        }

        Ok(output)
    }

    /// Test helper: Check git status in a specific directory
    async fn check_git_status_in_dir(dir: &std::path::Path) -> Result<String> {
        let output = git_in_dir(dir, &["status", "--porcelain"]).await?;
        Ok(String::from_utf8_lossy(&output.stdout).to_string())
    }

    /// Test helper: Get last commit message in a specific directory
    async fn get_last_commit_message_in_dir(dir: &std::path::Path) -> Result<String> {
        let output = git_in_dir(dir, &["log", "-1", "--pretty=format:%s"]).await?;
        Ok(String::from_utf8_lossy(&output.stdout).to_string())
    }

    /// Test helper: Stage all changes in a specific directory
    pub(super) async fn stage_all_changes_in_dir(dir: &std::path::Path) -> Result<()> {
        git_in_dir(dir, &["add", "."]).await?;
        Ok(())
    }

    /// Test helper: Create commit in a specific directory
    pub(super) async fn create_commit_in_dir(dir: &std::path::Path, message: &str) -> Result<()> {
        git_in_dir(dir, &["commit", "-m", message]).await?;
        Ok(())
    }

    #[tokio::test]
    async fn test_git_mutex_prevents_races() {
        // Create tasks that would race without synchronization
        let tasks: Vec<_> = (0..5)
            .map(|i| {
                tokio::spawn(async move {
                    // This would normally cause race conditions
                    let result = get_last_commit_message().await;
                    println!("Task {} completed: {:?}", i, result.is_ok());
                    result
                })
            })
            .collect();

        // All tasks should complete without race conditions
        for task in tasks {
            let _ = task.await;
        }
    }

    #[tokio::test]
    async fn test_is_git_repo() {
        // Create a temp directory with a git repo
        let temp_dir = TempDir::new().unwrap();

        // Test non-git directory
        let output = Command::new("git")
            .args(["rev-parse", "--git-dir"])
            .current_dir(temp_dir.path())
            .output()
            .await
            .unwrap();
        assert!(
            !output.status.success(),
            "Should not be a git repo initially"
        );

        // Initialize git repo
        let output = Command::new("git")
            .args(["init"])
            .current_dir(temp_dir.path())
            .output()
            .await
            .unwrap();

        // Ensure git init succeeded
        assert!(output.status.success(), "git init failed: {output:?}");

        // Test git directory
        let output = Command::new("git")
            .args(["rev-parse", "--git-dir"])
            .current_dir(temp_dir.path())
            .output()
            .await
            .unwrap();
        assert!(output.status.success(), "Should be a git repo after init");
    }

    /// Test helper: Create a temporary git repository
    pub(super) async fn create_temp_git_repo() -> Result<TempDir> {
        let temp_dir = TempDir::new()?;

        // Initialize git repo
        Command::new("git")
            .args(["init"])
            .current_dir(temp_dir.path())
            .output()
            .await?;

        // Configure git user for commits
        Command::new("git")
            .args(["config", "user.email", "test@example.com"])
            .current_dir(temp_dir.path())
            .output()
            .await?;

        Command::new("git")
            .args(["config", "user.name", "Test User"])
            .current_dir(temp_dir.path())
            .output()
            .await?;

        Ok(temp_dir)
    }

    /// Test helper: Create a commit in a repository
    async fn create_test_commit(repo_path: &std::path::Path, message: &str) -> Result<()> {
        // Create a unique file to avoid conflicts
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_millis();
        let filename = format!("test_{timestamp}.txt");
        let file_path = repo_path.join(&filename);
        std::fs::write(&file_path, "test content")?;

        // Stage the file
        Command::new("git")
            .args(["add", &filename])
            .current_dir(repo_path)
            .output()
            .await?;

        // Create commit
        Command::new("git")
            .args(["commit", "-m", message])
            .current_dir(repo_path)
            .output()
            .await?;

        Ok(())
    }

    #[tokio::test]
    async fn test_get_last_commit_message_success() {
        // Test getting last commit message in a valid repo
        let temp_repo = create_temp_git_repo().await.unwrap();

        // Create commits
        create_test_commit(temp_repo.path(), "Initial commit")
            .await
            .unwrap();
        create_test_commit(temp_repo.path(), "Feature: Add new functionality")
            .await
            .unwrap();

        let result = get_last_commit_message_in_dir(temp_repo.path()).await;
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), "Feature: Add new functionality");
    }

    #[tokio::test]
    async fn test_get_last_commit_message_no_commits() {
        // Test error when no commits exist
        let temp_repo = create_temp_git_repo().await.unwrap();

        let result = get_last_commit_message_in_dir(temp_repo.path()).await;
        assert!(result.is_err());
        // Git error messages vary by version, so just check it failed
    }

    #[tokio::test]
    async fn test_stage_all_changes_success() {
        // Test staging all changes successfully
        let temp_repo = create_temp_git_repo().await.unwrap();

        // Create initial commit
        create_test_commit(temp_repo.path(), "Initial commit")
            .await
            .unwrap();

        // Create a new file
        std::fs::write(temp_repo.path().join("new_file.txt"), "content").unwrap();

        let result = stage_all_changes_in_dir(temp_repo.path()).await;
        assert!(result.is_ok());

        // Verify file is staged
        let status = check_git_status_in_dir(temp_repo.path()).await.unwrap();
        assert!(status.contains("new_file.txt"));
    }

    #[tokio::test]
    async fn test_stage_all_changes_no_changes() {
        // Test staging when no changes exist
        let temp_repo = create_temp_git_repo().await.unwrap();

        // Create initial commit
        create_test_commit(temp_repo.path(), "Initial commit")
            .await
            .unwrap();

        let result = stage_all_changes_in_dir(temp_repo.path()).await;
        assert!(result.is_ok()); // Should succeed even with no changes
    }

    #[tokio::test]
    async fn test_create_commit_success() {
        // Test creating a commit successfully
        let temp_repo = create_temp_git_repo().await.unwrap();

        // Create initial commit
        create_test_commit(temp_repo.path(), "Initial commit")
            .await
            .unwrap();

        // Stage a change
        std::fs::write(temp_repo.path().join("new_test.txt"), "new content").unwrap();
        stage_all_changes_in_dir(temp_repo.path()).await.unwrap();

        let result = create_commit_in_dir(temp_repo.path(), "test: Add test file").await;
        assert!(result.is_ok());

        let last_message = get_last_commit_message_in_dir(temp_repo.path())
            .await
            .unwrap();
        assert_eq!(last_message, "test: Add test file");
    }

    #[tokio::test]
    async fn test_create_commit_no_staged_changes() {
        // Test error when no changes are staged
        let temp_repo = create_temp_git_repo().await.unwrap();

        // Create initial commit
        create_test_commit(temp_repo.path(), "Initial commit")
            .await
            .unwrap();

        let result = create_commit_in_dir(temp_repo.path(), "test: Empty commit").await;
        assert!(result.is_err());
        // Git will reject commits with no changes
    }

    #[tokio::test]
    async fn test_check_git_status_success() {
        // Test with clean repo
        let temp_dir = create_temp_git_repo().await.unwrap();

        let status = check_git_status_in_dir(temp_dir.path()).await.unwrap();
        // The --porcelain output is empty for a clean repo
        assert_eq!(status.trim(), "", "Expected empty status for clean repo");
    }

    #[tokio::test]
    async fn test_check_git_status_with_changes() {
        // Test with uncommitted changes
        let temp_dir = create_temp_git_repo().await.unwrap();

        // Create a file
        std::fs::write(temp_dir.path().join("test.txt"), "test content").unwrap();

        let status = check_git_status_in_dir(temp_dir.path()).await.unwrap();
        // The --porcelain output shows untracked files with ??
        assert!(
            status.contains("?? test.txt"),
            "Expected untracked file in status: {status}"
        );
    }

    // ================== PUBLIC API TESTS ==================
    // These tests verify that the public API functions work correctly.
    // We test them in the actual MMM repository since they use a global
    // singleton that operates on the current directory.

    #[tokio::test]
    async fn test_public_api_functions_in_real_repo() {
        // Skip test if not in a git repository (e.g., during CI or certain test environments)
        if !is_git_repo().await {
            eprintln!("Skipping test - not in a git repository");
            return;
        }

        // Test git_command - use a safe read-only command
        let result = git_command(&["status", "--porcelain"], "Check status").await;
        assert!(result.is_ok(), "git_command should succeed in MMM repo");

        // Test check_git_status
        let status = check_git_status().await;
        assert!(status.is_ok(), "Should be able to check status in MMM repo");

        // Test get_last_commit_message - MMM repo should have commits
        let message = get_last_commit_message().await;
        assert!(
            message.is_ok(),
            "Should get last commit message in MMM repo"
        );

        // The other functions (stage_all_changes, create_commit) modify the repo
        // so we don't test them here to avoid affecting the actual repository
    }

    #[tokio::test]
    async fn test_mutex_synchronization() {
        // Test that the mutex properly synchronizes access
        use std::sync::atomic::{AtomicUsize, Ordering};
        use std::sync::Arc;

        let counter = Arc::new(AtomicUsize::new(0));
        let tasks: Vec<_> = (0..5)
            .map(|_| {
                let counter = counter.clone();
                tokio::spawn(async move {
                    // This should be serialized by the mutex
                    let _result = check_git_status().await;
                    counter.fetch_add(1, Ordering::SeqCst);
                })
            })
            .collect();

        for task in tasks {
            task.await.unwrap();
        }

        assert_eq!(
            counter.load(Ordering::SeqCst),
            5,
            "All tasks should complete"
        );
    }
}

#[cfg(test)]
mod mock_tests {
    use super::tests::{create_commit_in_dir, create_temp_git_repo, stage_all_changes_in_dir};

    #[tokio::test]
    async fn test_stage_all_changes_with_mock() {
        // This test verifies staging works in an isolated environment
        let temp_repo = match create_temp_git_repo().await {
            Ok(repo) => repo,
            Err(e) => {
                eprintln!("Skipping test - could not create temp git repo: {e}");
                return;
            }
        };

        // Create a file to stage using directory-specific path
        std::fs::write(temp_repo.path().join("test_stage.txt"), "content").unwrap();

        // Test staging using directory-specific operation
        let result = stage_all_changes_in_dir(temp_repo.path()).await;

        assert!(result.is_ok(), "stage_all_changes_in_dir should succeed");
    }

    #[tokio::test]
    async fn test_create_commit_with_mock() {
        // This test verifies git operations work in an isolated environment
        let temp_repo = match create_temp_git_repo().await {
            Ok(repo) => repo,
            Err(e) => {
                eprintln!("Skipping test - could not create temp git repo: {e}");
                return;
            }
        };

        // Create and stage a file using directory-specific operations
        std::fs::write(temp_repo.path().join("test_commit.txt"), "content").unwrap();
        let stage_result = stage_all_changes_in_dir(temp_repo.path()).await;
        assert!(
            stage_result.is_ok(),
            "stage_all_changes_in_dir should succeed"
        );

        // Test commit creation using directory-specific operation
        let result = create_commit_in_dir(temp_repo.path(), "test: mock commit").await;

        assert!(result.is_ok(), "create_commit_in_dir should succeed");
    }
}