ralph-workflow 0.7.18

PROMPT-driven multi-agent orchestrator for git repos
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
// git_helpers/repo/discovery/io.rs — boundary module for git repository discovery and protection scope.
// File stem is `io` — recognized as boundary module by forbid_io_effects lint.

use std::fs;
use std::path::{Path, PathBuf};

use crate::git_helpers::git2_to_io_error;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtectionScope {
    pub repo_root: PathBuf,
    pub git_dir: PathBuf,
    pub common_git_dir: PathBuf,
    pub hooks_dir: PathBuf,
    pub ralph_dir: PathBuf,
    pub is_linked_worktree: bool,
    pub uses_worktree_scoped_hooks: bool,
    pub worktree_config_path: Option<PathBuf>,
}

/// Resolve the active git-protection scope for the current repository context.
///
/// # Errors
///
/// Returns an error when the current directory is not inside a git worktree or
/// when the repository workdir cannot be determined.
pub fn resolve_protection_scope() -> std::io::Result<ProtectionScope> {
    resolve_protection_scope_from(Path::new("."))
}

/// Resolve the active git-protection scope for an explicit discovery root.
///
/// # Errors
///
/// Returns an error when `discovery_root` is not inside a git worktree or when
/// the repository workdir cannot be determined.
fn worktree_config_path_for(
    uses_worktree_scoped_hooks: bool,
    is_linked_worktree: bool,
    git_dir: &Path,
    common_git_dir: &Path,
) -> Option<PathBuf> {
    uses_worktree_scoped_hooks.then(|| {
        if is_linked_worktree {
            git_dir.join("config.worktree")
        } else {
            common_git_dir.join("config.worktree")
        }
    })
}

fn hooks_dir_for_scope(
    uses_worktree_scoped_hooks: bool,
    ralph_dir: &Path,
    git_dir: &Path,
) -> PathBuf {
    if uses_worktree_scoped_hooks {
        ralph_dir.join("hooks")
    } else {
        git_dir.join("hooks")
    }
}

fn compute_worktree_flags(
    repo: &git2::Repository,
    git_dir: &std::path::Path,
    common_git_dir: &std::path::Path,
) -> (bool, bool) {
    let is_linked_worktree = repo.is_worktree() && git_dir != common_git_dir;
    let has_linked_worktrees = common_git_dir.join("worktrees").is_dir();
    (is_linked_worktree, is_linked_worktree || has_linked_worktrees)
}

fn build_protection_scope(repo: &git2::Repository) -> std::io::Result<ProtectionScope> {
    let repo_root = repo.workdir().map(PathBuf::from).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "No workdir for repository")
    })?;
    let git_dir = repo.path().to_path_buf();
    let common_git_dir = common_git_dir(repo);
    let (is_linked_worktree, uses_worktree_scoped_hooks) =
        compute_worktree_flags(repo, &git_dir, &common_git_dir);
    let worktree_config_path =
        worktree_config_path_for(uses_worktree_scoped_hooks, is_linked_worktree, &git_dir, &common_git_dir);
    let ralph_dir = git_dir.join("ralph");
    let hooks_dir = hooks_dir_for_scope(uses_worktree_scoped_hooks, &ralph_dir, &git_dir);
    Ok(ProtectionScope {
        repo_root,
        git_dir,
        common_git_dir,
        hooks_dir,
        ralph_dir,
        is_linked_worktree,
        uses_worktree_scoped_hooks,
        worktree_config_path,
    })
}

pub fn resolve_protection_scope_from(discovery_root: &Path) -> std::io::Result<ProtectionScope> {
    let repo = git2::Repository::discover(discovery_root).map_err(|e| git2_to_io_error(&e))?;
    build_protection_scope(&repo)
}

/// Returns the path to the `ralph` subdirectory inside the git metadata directory.
///
/// This directory holds Ralph's runtime enforcement state (marker, track file, head-oid).
/// It is inside the active git dir for the current repository context and is therefore
/// invisible to working-tree scans.
///
/// Falls back to `repo_root/.git/ralph` if libgit2 discovery fails (e.g., plain temp
/// directories used in unit tests).
pub fn ralph_git_dir(repo_root: &Path) -> PathBuf {
    if let Ok(scope) = resolve_protection_scope_from(repo_root) {
        return scope.ralph_dir;
    }
    // Fallback: assume standard .git directory layout.
    repo_root.join(".git").join("ralph")
}

pub fn normalize_protection_scope_path(path: &Path) -> PathBuf {
    if let Ok(canonical) = fs::canonicalize(path) {
        return canonical;
    }

    let existing_ancestor = find_existing_ancestor(path);
    if existing_ancestor == path {
        return path.to_path_buf();
    }

    build_normalized_path(path, &existing_ancestor)
}

fn find_existing_ancestor(path: &Path) -> PathBuf {
    path.ancestors()
        .find(|ancestor| ancestor.exists())
        .map(PathBuf::from)
        .unwrap_or_else(|| path.to_path_buf())
}

fn build_normalized_path(path: &Path, existing_ancestor: &Path) -> PathBuf {
    let Ok(canonical_ancestor) = fs::canonicalize(existing_ancestor) else {
        return path.to_path_buf();
    };

    let suffix = path
        .strip_prefix(existing_ancestor)
        .unwrap_or_else(|_| Path::new(""));
    canonical_ancestor.join(suffix)
}

fn build_tampered_path(path: &Path, label: &str) -> std::io::Result<PathBuf> {
    let parent = path.parent().ok_or_else(|| {
        std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "path has no parent directory",
        )
    })?;
    let file_name = path.file_name().ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no file name")
    })?;
    let suffix = format!(
        "ralph.tampered.{label}.{}.{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos()
    );
    Ok(parent.join(format!("{}.{}", file_name.to_string_lossy(), suffix)))
}

fn is_empty_dir(path: &Path) -> bool {
    fs::symlink_metadata(path).ok().is_some_and(|m| m.is_dir())
        && fs::read_dir(path).ok().is_some_and(|it| it.count() == 0)
}

pub fn quarantine_path_in_place(path: &Path, label: &str) -> std::io::Result<PathBuf> {
    let tampered_path = build_tampered_path(path, label)?;
    match fs::rename(path, &tampered_path) {
        Ok(()) => Ok(tampered_path),
        Err(_) if is_empty_dir(path) => {
            fs::remove_dir(path)?;
            Ok(path.to_path_buf())
        }
        Err(e) => Err(e),
    }
}

fn prepare_ralph_git_dir_internal(
    ralph_dir: &Path,
    create_if_missing: bool,
) -> std::io::Result<bool> {
    match fs::symlink_metadata(ralph_dir) {
        Ok(meta) => handle_existing_ralph_dir(ralph_dir, &meta, create_if_missing),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            if !create_if_missing {
                return Ok(false);
            }
            fs::create_dir_all(ralph_dir)?;
            verify_created_ralph_dir(ralph_dir)
        }
        Err(e) => Err(e),
    }
}

fn handle_existing_ralph_dir(
    ralph_dir: &Path,
    meta: &fs::Metadata,
    create_if_missing: bool,
) -> std::io::Result<bool> {
    let ft = meta.file_type();
    if ft.is_symlink() || !meta.is_dir() {
        quarantine_path_in_place(ralph_dir, "dir")?;
        if !create_if_missing {
            return Ok(false);
        }
        fs::create_dir_all(ralph_dir)?;
        verify_created_ralph_dir(ralph_dir)
    } else {
        Ok(true)
    }
}

fn verify_created_ralph_dir(ralph_dir: &Path) -> std::io::Result<bool> {
    let meta = fs::symlink_metadata(ralph_dir)?;
    let ft = meta.file_type();
    if ft.is_symlink() || !meta.is_dir() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            "ralph git dir is not a regular directory",
        ));
    }
    Ok(true)
}

pub fn ensure_ralph_git_dir(repo_root: &Path) -> std::io::Result<PathBuf> {
    let ralph_dir = ralph_git_dir(repo_root);
    prepare_ralph_git_dir_internal(&ralph_dir, true)?;
    Ok(ralph_dir)
}

pub fn sanitize_ralph_git_dir_at(ralph_dir: &Path) -> std::io::Result<bool> {
    prepare_ralph_git_dir_internal(ralph_dir, false)
}

/// Check if we're in a git repository.
///
/// # Errors
///
/// Returns error if the operation fails.
pub fn require_git_repo() -> std::io::Result<()> {
    git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
    Ok(())
}

/// Get the git repository root.
///
/// # Errors
///
/// Returns error if the operation fails.
pub fn get_repo_root() -> std::io::Result<PathBuf> {
    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
    repo.workdir().map(PathBuf::from).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "No workdir for repository")
    })
}

pub fn get_hooks_dir_from(discovery_root: &Path) -> std::io::Result<PathBuf> {
    Ok(resolve_protection_scope_from(discovery_root)?.hooks_dir)
}

/// Returns the common git directory for a repository.
///
/// For main worktrees, this is the same as `repo.path()`.
/// For linked worktrees, this navigates from `.git/worktrees/<name>/`
/// up to the shared `.git/` directory.
///
/// This is needed because git2 0.18 does not expose `Repository::commondir()`.
fn linked_worktree_common_git_dir(git_dir: &Path) -> Option<PathBuf> {
    let worktrees_dir = git_dir.parent()?;
    if worktrees_dir.file_name().and_then(|n| n.to_str()) != Some("worktrees") {
        return None;
    }
    worktrees_dir.parent().map(Path::to_path_buf)
}

fn common_git_dir(repo: &git2::Repository) -> PathBuf {
    let path = repo.path();
    if repo.is_worktree() {
        if let Some(common) = linked_worktree_common_git_dir(path) {
            return common;
        }
    }
    path.to_path_buf()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Helper: create a git repo with an initial commit (required for worktree creation).
    fn init_repo_with_commit(path: &Path) -> git2::Repository {
        let repo = git2::Repository::init(path).unwrap();
        {
            let mut index = repo.index().unwrap();
            let tree_oid = index.write_tree().unwrap();
            let tree = repo.find_tree(tree_oid).unwrap();
            let sig = git2::Signature::now("test", "test@test.com").unwrap();
            repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
                .unwrap();
        }
        repo
    }

    fn canon(path: &Path) -> PathBuf {
        normalize_protection_scope_path(path)
    }

    #[test]
    fn resolve_protection_scope_for_regular_repo_uses_main_git_dir_for_all_paths() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = git2::Repository::init(tmp.path()).unwrap();

        let scope = resolve_protection_scope_from(tmp.path()).unwrap();

        assert!(!scope.is_linked_worktree);
        assert_eq!(canon(&scope.git_dir), canon(repo.path()));
        assert_eq!(canon(&scope.common_git_dir), canon(repo.path()));
        assert_eq!(
            canon(&scope.hooks_dir),
            canon(&tmp.path().join(".git/hooks"))
        );
        assert_eq!(
            canon(&scope.ralph_dir),
            canon(&tmp.path().join(".git/ralph"))
        );
        assert!(!scope.uses_worktree_scoped_hooks);
        assert_eq!(scope.worktree_config_path, None);
    }

    #[test]
    fn resolve_protection_scope_for_linked_worktree_keeps_common_and_active_git_dirs_distinct() {
        let tmp = tempfile::tempdir().unwrap();
        let main_repo = init_repo_with_commit(tmp.path());
        let wt_path = tmp.path().join("wt-test");
        let _wt = main_repo.worktree("wt-test", &wt_path, None).unwrap();
        let wt_repo = git2::Repository::open(&wt_path).unwrap();

        let scope = resolve_protection_scope_from(&wt_path).unwrap();

        assert!(scope.is_linked_worktree);
        assert!(scope.uses_worktree_scoped_hooks);
        assert_eq!(canon(&scope.git_dir), canon(wt_repo.path()));
        assert_eq!(canon(&scope.common_git_dir), canon(main_repo.path()));
        assert_ne!(canon(&scope.git_dir), canon(&scope.common_git_dir));
        assert_eq!(
            scope.worktree_config_path.as_deref().map(canon),
            Some(canon(&wt_repo.path().join("config.worktree")))
        );
    }

    #[test]
    fn resolve_protection_scope_for_linked_worktree_uses_worktree_local_hook_and_ralph_dirs() {
        let tmp = tempfile::tempdir().unwrap();
        let main_repo = init_repo_with_commit(tmp.path());
        let wt_path = tmp.path().join("wt-test");
        let _wt = main_repo.worktree("wt-test", &wt_path, None).unwrap();
        let wt_repo = git2::Repository::open(&wt_path).unwrap();

        let scope = resolve_protection_scope_from(&wt_path).unwrap();

        assert_eq!(
            canon(&scope.hooks_dir),
            canon(&wt_repo.path().join("ralph/hooks"))
        );
        assert_eq!(
            canon(&scope.ralph_dir),
            canon(&wt_repo.path().join("ralph"))
        );
        assert_ne!(
            canon(&scope.hooks_dir),
            canon(&tmp.path().join(".git/hooks"))
        );
        assert_ne!(
            canon(&scope.ralph_dir),
            canon(&tmp.path().join(".git/ralph"))
        );
    }

    #[test]
    fn resolve_protection_scope_for_main_worktree_with_linked_siblings_uses_main_worktree_config() {
        let tmp = tempfile::tempdir().unwrap();
        let main_repo = init_repo_with_commit(tmp.path());
        let wt_path = tmp.path().join("wt-test");
        let _wt = main_repo.worktree("wt-test", &wt_path, None).unwrap();

        let scope = resolve_protection_scope_from(tmp.path()).unwrap();

        assert!(!scope.is_linked_worktree);
        assert!(scope.uses_worktree_scoped_hooks);
        assert_eq!(canon(&scope.git_dir), canon(&scope.common_git_dir));
        assert_eq!(
            canon(&scope.hooks_dir),
            canon(&tmp.path().join(".git/ralph/hooks"))
        );
        assert_eq!(
            scope.worktree_config_path.as_deref().map(canon),
            Some(canon(&tmp.path().join(".git/config.worktree")))
        );
    }

    #[cfg(unix)]
    #[test]
    fn normalize_protection_scope_path_collapses_symlink_aliases_for_scope_comparison() {
        use std::os::unix::fs::symlink;

        let tmp = tempfile::tempdir().unwrap();
        let repo_path = tmp.path().join("repo");
        fs::create_dir_all(&repo_path).unwrap();

        let alias_parent = tmp.path().join("aliases");
        fs::create_dir_all(&alias_parent).unwrap();
        let alias_path = alias_parent.join("repo-link");
        symlink(&repo_path, &alias_path).unwrap();

        assert_eq!(
            normalize_protection_scope_path(&repo_path),
            normalize_protection_scope_path(&alias_path),
            "scope comparison should treat symlink aliases as the same repository path"
        );

        let real_git_dir = repo_path.join(".git");
        let alias_git_dir = alias_path.join(".git");
        assert_eq!(
            normalize_protection_scope_path(&real_git_dir),
            normalize_protection_scope_path(&alias_git_dir),
            "scope comparison should normalize git-dir aliases too"
        );
    }
}