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
//! Integration tests for MapReduce Setup Phase Worktree Isolation
//!
//! Verifies that the setup phase executes in an isolated worktree and that
//! the main repository remains clean (no modified files or commits) during
//! setup phase execution.

use std::fs;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;

/// Helper to initialize a test git repository
fn init_test_repo(path: &PathBuf) {
    Command::new("git")
        .args(["init"])
        .current_dir(path)
        .output()
        .expect("Failed to init git repo");

    Command::new("git")
        .args(["config", "user.name", "Test User"])
        .current_dir(path)
        .output()
        .expect("Failed to set git user name");

    Command::new("git")
        .args(["config", "user.email", "test@example.com"])
        .current_dir(path)
        .output()
        .expect("Failed to set git user email");

    // Create initial commit
    fs::write(path.join("README.md"), "# Test Repo\n").expect("Failed to write README");

    Command::new("git")
        .args(["add", "README.md"])
        .current_dir(path)
        .output()
        .expect("Failed to git add");

    Command::new("git")
        .args(["commit", "-m", "Initial commit"])
        .current_dir(path)
        .output()
        .expect("Failed to git commit");
}

/// Helper to get git status output
fn get_git_status(path: &PathBuf) -> String {
    let output = Command::new("git")
        .args(["status", "--porcelain"])
        .current_dir(path)
        .output()
        .expect("Failed to run git status");

    String::from_utf8_lossy(&output.stdout).to_string()
}

/// Helper to count commits on current branch
fn count_commits(path: &PathBuf) -> usize {
    let output = Command::new("git")
        .args(["rev-list", "--count", "HEAD"])
        .current_dir(path)
        .output()
        .expect("Failed to count commits");

    String::from_utf8_lossy(&output.stdout)
        .trim()
        .parse()
        .expect("Failed to parse commit count")
}

/// Helper to check if a file is committed on current branch
fn is_file_committed(path: &PathBuf, filename: &str) -> bool {
    let output = Command::new("git")
        .args(["ls-tree", "-r", "HEAD", "--name-only"])
        .current_dir(path)
        .output()
        .expect("Failed to list files");

    String::from_utf8_lossy(&output.stdout).contains(filename)
}

/// Helper to create a worktree
fn create_worktree(repo_path: &PathBuf, worktree_name: &str) -> PathBuf {
    let worktree_path = repo_path.parent().unwrap().join(worktree_name);

    Command::new("git")
        .args([
            "worktree",
            "add",
            "-b",
            worktree_name,
            worktree_path.to_str().unwrap(),
        ])
        .current_dir(repo_path)
        .output()
        .expect("Failed to create worktree");

    worktree_path
}

#[test]
fn test_setup_phase_modifies_worktree_not_main_repo() {
    // Create a test repository
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let main_repo = temp_dir.path().join("main-repo");
    fs::create_dir_all(&main_repo).expect("Failed to create main repo dir");
    init_test_repo(&main_repo);

    // Get initial state of main repo
    let main_status_before = get_git_status(&main_repo);
    let main_commits_before = count_commits(&main_repo);

    // Create a worktree for setup phase
    let worktree_path = create_worktree(&main_repo, "setup-worktree");

    // Simulate setup phase: create a file in the worktree
    fs::write(
        worktree_path.join("work-items.json"),
        r#"{"items": [{"id": 1}]}"#,
    )
    .expect("Failed to write work-items.json");

    // Commit the change in the worktree
    Command::new("git")
        .args(["add", "work-items.json"])
        .current_dir(&worktree_path)
        .output()
        .expect("Failed to git add in worktree");

    Command::new("git")
        .args(["commit", "-m", "Setup phase: generate work items"])
        .current_dir(&worktree_path)
        .output()
        .expect("Failed to git commit in worktree");

    // Verify main repo status is still clean
    let main_status_after = get_git_status(&main_repo);
    assert_eq!(
        main_status_before, main_status_after,
        "Main repository should have no modified files after setup phase"
    );

    // Verify main repo branch has same number of commits (setup commit is in worktree branch only)
    let main_commits_after = count_commits(&main_repo);
    assert_eq!(
        main_commits_before, main_commits_after,
        "Main repository branch should have no new commits after setup phase (commits are in worktree branch)"
    );

    // Verify work-items.json is not committed in main branch
    assert!(
        !is_file_committed(&main_repo, "work-items.json"),
        "work-items.json should not be committed on main branch"
    );

    // Verify worktree has the changes
    let worktree_status = get_git_status(&worktree_path);
    assert_eq!(
        worktree_status.trim(),
        "",
        "Worktree should have no uncommitted changes (changes were committed)"
    );

    let worktree_commits = count_commits(&worktree_path);
    assert_eq!(
        worktree_commits,
        main_commits_before + 1,
        "Worktree should have one more commit than main repo"
    );

    // Verify the file exists in worktree but not in main repo
    assert!(
        worktree_path.join("work-items.json").exists(),
        "work-items.json should exist in worktree"
    );
    assert!(
        !main_repo.join("work-items.json").exists(),
        "work-items.json should NOT exist in main repo"
    );
}

#[test]
fn test_setup_phase_commits_in_worktree() {
    // Create a test repository
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let main_repo = temp_dir.path().join("main-repo");
    fs::create_dir_all(&main_repo).expect("Failed to create main repo dir");
    init_test_repo(&main_repo);

    // Get initial commit count
    let main_commits_before = count_commits(&main_repo);

    // Create a worktree for setup phase
    let worktree_path = create_worktree(&main_repo, "setup-worktree-commits");

    // Simulate setup phase with multiple commits
    for i in 1..=3 {
        let filename = format!("setup-file-{}.txt", i);
        fs::write(
            worktree_path.join(&filename),
            format!("Setup content {}", i),
        )
        .expect("Failed to write setup file");

        Command::new("git")
            .args(["add", &filename])
            .current_dir(&worktree_path)
            .output()
            .expect("Failed to git add");

        Command::new("git")
            .args(["commit", "-m", &format!("Setup step {}", i)])
            .current_dir(&worktree_path)
            .output()
            .expect("Failed to git commit");
    }

    // Verify main repo has no new commits
    let main_commits_after = count_commits(&main_repo);
    assert_eq!(
        main_commits_before, main_commits_after,
        "Main repository should have no new commits after setup phase"
    );

    // Verify worktree has 3 additional commits
    let worktree_commits = count_commits(&worktree_path);
    assert_eq!(
        worktree_commits,
        main_commits_before + 3,
        "Worktree should have 3 more commits than main repo"
    );

    // Verify main repo working directory is still clean
    let main_status = get_git_status(&main_repo);
    assert_eq!(
        main_status.trim(),
        "",
        "Main repository working directory should be clean"
    );

    // Verify the setup files don't exist in main repo
    for i in 1..=3 {
        let filename = format!("setup-file-{}.txt", i);
        assert!(
            !main_repo.join(&filename).exists(),
            "{} should NOT exist in main repo",
            filename
        );
        assert!(
            worktree_path.join(&filename).exists(),
            "{} should exist in worktree",
            filename
        );
    }
}

#[tokio::test]
async fn test_setup_phase_execution_context_validation() {
    // This test verifies that validate_execution_context() prevents
    // execution in the wrong directory

    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let main_repo = temp_dir.path().join("main-repo");
    fs::create_dir_all(&main_repo).expect("Failed to create main repo dir");

    // Create a worktree path
    let worktree_path = temp_dir.path().join("worktrees").join("session-test");
    fs::create_dir_all(&worktree_path).expect("Failed to create worktree dir");

    // Test 1: Validation should succeed when current_dir matches expected_dir
    let original_dir = std::env::current_dir().expect("Failed to get current dir");
    std::env::set_current_dir(&worktree_path).expect("Failed to change to worktree dir");

    // This would be called by setup_executor's validate_execution_context
    let current_dir = std::env::current_dir().expect("Failed to get current dir");
    // Canonicalize both paths to handle /private/var vs /var on macOS
    let canonical_current = current_dir
        .canonicalize()
        .expect("Failed to canonicalize current dir");
    let canonical_worktree = worktree_path
        .canonicalize()
        .expect("Failed to canonicalize worktree path");
    assert_eq!(
        canonical_current, canonical_worktree,
        "Current directory should match worktree path"
    );

    // Restore original directory
    std::env::set_current_dir(original_dir).expect("Failed to restore directory");

    // Test 2: Validation should fail when current_dir doesn't match
    // (This is implicitly tested by the function, but we verify the concept here)
    let current_dir = std::env::current_dir().expect("Failed to get current dir");
    let canonical_current = current_dir
        .canonicalize()
        .expect("Failed to canonicalize current dir");
    let canonical_worktree = worktree_path
        .canonicalize()
        .expect("Failed to canonicalize worktree path");
    assert_ne!(
        canonical_current, canonical_worktree,
        "Current directory should NOT match worktree path after restoration"
    );
}

#[test]
fn test_main_repo_isolation_guarantee() {
    // This test provides the strongest guarantee: main repo is NEVER touched
    // during setup phase execution

    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let main_repo = temp_dir.path().join("main-repo");
    fs::create_dir_all(&main_repo).expect("Failed to create main repo dir");
    init_test_repo(&main_repo);

    // Take a snapshot of main repo state
    let main_files_before: Vec<_> = fs::read_dir(&main_repo)
        .expect("Failed to read main repo")
        .filter_map(|e| e.ok())
        .map(|e| e.file_name())
        .collect();

    let main_status_before = get_git_status(&main_repo);
    let main_commits_before = count_commits(&main_repo);

    // Create worktree and simulate intensive setup operations
    let worktree_path = create_worktree(&main_repo, "intensive-setup");

    // Perform multiple operations in worktree
    for i in 1..=10 {
        let filename = format!("generated-{}.json", i);
        fs::write(worktree_path.join(&filename), format!(r#"{{"id": {}}}"#, i))
            .expect("Failed to write file");

        Command::new("git")
            .args(["add", &filename])
            .current_dir(&worktree_path)
            .output()
            .expect("Failed to git add");

        Command::new("git")
            .args(["commit", "-m", &format!("Generate {}", filename)])
            .current_dir(&worktree_path)
            .output()
            .expect("Failed to git commit");
    }

    // Verify main repo is completely unchanged
    let main_files_after: Vec<_> = fs::read_dir(&main_repo)
        .expect("Failed to read main repo")
        .filter_map(|e| e.ok())
        .map(|e| e.file_name())
        .collect();

    assert_eq!(
        main_files_before, main_files_after,
        "Main repository file list should be unchanged"
    );

    let main_status_after = get_git_status(&main_repo);
    assert_eq!(
        main_status_before, main_status_after,
        "Main repository git status should be unchanged"
    );

    let main_commits_after = count_commits(&main_repo);
    assert_eq!(
        main_commits_before, main_commits_after,
        "Main repository commit count should be unchanged"
    );

    // Verify all operations happened in worktree
    let worktree_commits = count_commits(&worktree_path);
    assert_eq!(
        worktree_commits,
        main_commits_before + 10,
        "Worktree should have 10 additional commits"
    );
}

#[test]
fn test_worktree_path_isolation() {
    // Test that file operations in worktree don't affect main repo

    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let main_repo = temp_dir.path().join("main-repo");
    fs::create_dir_all(&main_repo).expect("Failed to create main repo dir");
    init_test_repo(&main_repo);

    // Create worktree
    let worktree_path = create_worktree(&main_repo, "path-isolation-test");

    // Create a file directly in the worktree
    fs::write(
        worktree_path.join("worktree-only.txt"),
        "This file exists only in worktree",
    )
    .expect("Failed to write file");

    // Verify file exists in worktree but not main repo
    assert!(
        worktree_path.join("worktree-only.txt").exists(),
        "File should exist in worktree"
    );
    assert!(
        !main_repo.join("worktree-only.txt").exists(),
        "File should NOT exist in main repo"
    );

    // Create a file in main repo
    fs::write(
        main_repo.join("main-repo-only.txt"),
        "This file exists only in main repo",
    )
    .expect("Failed to write file");

    // Verify file exists in main repo but not worktree
    assert!(
        main_repo.join("main-repo-only.txt").exists(),
        "File should exist in main repo"
    );
    assert!(
        !worktree_path.join("main-repo-only.txt").exists(),
        "File should NOT exist in worktree (until committed and checked out)"
    );
}