use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use lds_core::{Session, SessionConfig};
use lds_git::{GitModule, LogFilters, OtherStagedMode, ResetMode};
fn init_temp_repo(dir: &Path) {
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap();
};
run(&["init", "-b", "main"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "Test"]);
std::fs::write(dir.join("README.md"), "init\n").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "initial"]);
std::fs::create_dir_all(dir.join(".worktrees")).unwrap();
}
fn make_session(root: &Path) -> Arc<Session> {
Arc::new(
Session::new(SessionConfig {
root: root.to_path_buf(),
timeout_secs: Some(30),
..Default::default()
})
.unwrap(),
)
}
#[tokio::test]
async fn worktree_lifecycle() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let mut git = GitModule::new(session);
let list = git.worktree_list().await.unwrap();
assert_eq!(list.worktrees.len(), 1);
assert!(!list.worktrees[0].owned);
let add_result = git
.worktree_add("test-wt", "feat/test", Some("main"))
.await
.unwrap();
assert!(add_result.path.ends_with("test-wt"));
assert_eq!(add_result.branch, "feat/test");
let list = git.worktree_list().await.unwrap();
assert!(
list.worktrees.iter().any(|w| w.owned),
"expected at least one owned worktree, got: {list:?}"
);
let wt_path = tmp.path().join(".worktrees/test-wt");
std::fs::write(wt_path.join("new_file.txt"), "content\n").unwrap();
let commit_result = git
.commit(&wt_path, "test commit", None, OtherStagedMode::Stop, false)
.await
.unwrap();
assert_eq!(commit_result.sha.len(), 40, "expected full SHA-1");
assert_eq!(commit_result.message, "test commit");
assert_eq!(commit_result.files_changed, 1);
let merge_result = git.merge("feat/test", "main", tmp.path()).await.unwrap();
assert_eq!(merge_result.branch, "feat/test");
assert_eq!(merge_result.into_branch, "main");
assert_eq!(merge_result.sha.len(), 40);
let remove_result = git.worktree_remove("test-wt").await.unwrap();
assert!(remove_result.path.ends_with("test-wt"));
let delete_result = git.branch_delete("feat/test").await.unwrap();
assert_eq!(delete_result.branch, "feat/test");
assert!(tmp.path().join("new_file.txt").exists());
let log = git
.log(LogFilters {
max_count: 5,
..Default::default()
})
.await
.unwrap();
assert!(
log.commits
.iter()
.any(|c| c.summary.contains("Merge branch")),
"expected a 'Merge branch' commit in log, got: {log:?}"
);
}
#[tokio::test]
async fn log_filters_by_author_paths_and_since() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("git");
};
run(&["init", "-b", "main"]);
run(&["config", "user.email", "alice@example.com"]);
run(&["config", "user.name", "Alice"]);
std::fs::write(dir.join("a.txt"), "1\n").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "add a.txt"]);
run(&["config", "user.email", "bob@example.com"]);
run(&["config", "user.name", "Bob"]);
std::fs::write(dir.join("b.txt"), "2\n").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "add b.txt"]);
run(&["config", "user.email", "alice@example.com"]);
run(&["config", "user.name", "Alice"]);
std::fs::write(dir.join("a.txt"), "1\n2\n").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "update a.txt"]);
std::fs::create_dir_all(dir.join(".worktrees")).unwrap();
let session = make_session(dir);
let git = GitModule::new(session);
let bob_only = git
.log(LogFilters {
max_count: 10,
author: Some("Bob".to_string()),
..Default::default()
})
.await
.unwrap();
assert_eq!(bob_only.commits.len(), 1);
assert_eq!(bob_only.commits[0].summary, "add b.txt");
assert!(bob_only.commits[0].author.contains("bob@example.com"));
let touches_b = git
.log(LogFilters {
max_count: 10,
paths: Some(vec!["b.txt".to_string()]),
..Default::default()
})
.await
.unwrap();
assert_eq!(touches_b.commits.len(), 1);
assert_eq!(touches_b.commits[0].summary, "add b.txt");
let touches_a = git
.log(LogFilters {
max_count: 10,
paths: Some(vec!["a.txt".to_string()]),
..Default::default()
})
.await
.unwrap();
assert_eq!(touches_a.commits.len(), 2);
let head_ts = touches_a.commits[0].timestamp;
let none = git
.log(LogFilters {
max_count: 10,
since: Some(head_ts + 1),
..Default::default()
})
.await
.unwrap();
assert_eq!(none.commits.len(), 0);
let capped = git
.log(LogFilters {
max_count: 1,
author: Some("Alice".to_string()),
..Default::default()
})
.await
.unwrap();
assert_eq!(capped.commits.len(), 1);
let updates = git
.log(LogFilters {
max_count: 10,
grep: Some("update".to_string()),
..Default::default()
})
.await
.unwrap();
assert_eq!(updates.commits.len(), 1);
assert_eq!(updates.commits[0].summary, "update a.txt");
let no_match = git
.log(LogFilters {
max_count: 10,
grep: Some("does-not-appear".to_string()),
..Default::default()
})
.await
.unwrap();
assert_eq!(no_match.commits.len(), 0);
let head = &touches_a.commits[0];
assert_eq!(head.sha.len(), 40);
assert_eq!(head.short_sha.len(), 7);
assert!(head.author.starts_with("Alice <"));
assert!(head.timestamp > 0);
}
#[tokio::test]
async fn ownership_guard_rejects_unowned_worktree() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
Command::new("git")
.args([
"worktree",
"add",
"-b",
"other/branch",
tmp.path().join(".worktrees/foreign").to_str().unwrap(),
"main",
])
.current_dir(tmp.path())
.output()
.unwrap();
let foreign_path = tmp.path().join(".worktrees/foreign");
std::fs::write(foreign_path.join("file.txt"), "x").unwrap();
let err = git
.commit(
&foreign_path,
"bad commit",
None,
OtherStagedMode::Stop,
false,
)
.await;
assert!(err.is_err());
assert!(
err.unwrap_err()
.to_string()
.contains("not owned by this session")
);
let err = git.branch_delete("other/branch").await;
assert!(err.is_err());
assert!(
err.unwrap_err()
.to_string()
.contains("not owned by this session")
);
}
#[tokio::test]
async fn commit_allowed_at_session_root() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join("root_file.txt"), "content\n").unwrap();
let result = git
.commit(
tmp.path(),
"root commit",
Some(&["root_file.txt".to_string()]),
OtherStagedMode::Stop,
false,
)
.await;
let commit = result.expect("commit at session root");
assert_eq!(commit.sha.len(), 40);
assert_eq!(commit.message, "root commit");
}
#[tokio::test]
async fn status_partitions_staged_unstaged_untracked() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
let status = git.status().await.unwrap();
assert!(status.staged.is_empty(), "staged was {:?}", status.staged);
assert!(
status.unstaged.is_empty(),
"unstaged was {:?}",
status.unstaged
);
assert_eq!(status.branch.as_deref(), Some("main"));
assert!(status.head_sha.is_some());
std::fs::write(tmp.path().join("untracked.txt"), "u\n").unwrap();
let status = git.status().await.unwrap();
assert!(
status
.untracked
.iter()
.any(|p| p.ends_with("untracked.txt")),
"untracked was {:?}",
status.untracked
);
Command::new("git")
.args(["add", "untracked.txt"])
.current_dir(tmp.path())
.output()
.unwrap();
let status = git.status().await.unwrap();
assert!(
status
.staged
.iter()
.any(|e| e.path.ends_with("untracked.txt")),
"staged was {:?}",
status.staged
);
}
#[tokio::test]
async fn diff_distinguishes_staged_from_unstaged() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join("README.md"), "init\nchanged\n").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(tmp.path())
.output()
.unwrap();
let unstaged = git.diff(false).await.unwrap();
assert!(!unstaged.staged);
assert_eq!(
unstaged.file_count, 0,
"expected no unstaged changes, patch was: {:?}",
unstaged.patch
);
let staged = git.diff(true).await.unwrap();
assert!(staged.staged);
assert_eq!(staged.file_count, 1);
assert!(
staged.patch.contains("changed"),
"expected '+changed' line in staged patch, got: {:?}",
staged.patch
);
std::fs::write(tmp.path().join("README.md"), "init\nchanged\nagain\n").unwrap();
let unstaged = git.diff(false).await.unwrap();
assert!(!unstaged.staged);
assert_eq!(unstaged.file_count, 1);
assert!(
unstaged.patch.contains("again"),
"expected '+again' line in unstaged patch, got: {:?}",
unstaged.patch
);
}
#[tokio::test]
async fn reset_moves_head_back() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
let before = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(tmp.path())
.output()
.unwrap();
let before_sha = String::from_utf8_lossy(&before.stdout).trim().to_string();
std::fs::write(tmp.path().join("two.txt"), "two\n").unwrap();
Command::new("git")
.args(["add", "two.txt"])
.current_dir(tmp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "second"])
.current_dir(tmp.path())
.output()
.unwrap();
let result = git
.reset(tmp.path(), ResetMode::Hard, &before_sha)
.await
.expect("reset");
assert!(matches!(result.mode, ResetMode::Hard));
assert_eq!(result.target, before_sha);
assert_eq!(result.current_head, before_sha);
assert_ne!(result.previous_head, result.current_head);
}
#[tokio::test]
async fn session_release_adopts_orphan_worktree() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let mut git = GitModule::new(session);
Command::new("git")
.args([
"worktree",
"add",
"-b",
"left/over",
tmp.path().join(".worktrees/leftover").to_str().unwrap(),
"main",
])
.current_dir(tmp.path())
.output()
.unwrap();
let leftover = tmp.path().join(".worktrees/leftover");
assert!(git.branch_delete("left/over").await.is_err());
let release = git.session_release().await.expect("session_release");
let canonical_leftover = leftover.canonicalize().unwrap_or(leftover.clone());
assert!(
release.adopted_worktrees.iter().any(|p| {
let canon = p.canonicalize().unwrap_or_else(|_| p.clone());
canon == canonical_leftover
}),
"expected leftover to be adopted (canonical: {canonical_leftover:?}), got: {release:?}"
);
assert!(
release.adopted_branches.iter().any(|b| b == "left/over"),
"expected left/over branch adopted, got: {release:?}"
);
git.worktree_remove("leftover")
.await
.expect("worktree_remove after adoption");
git.branch_delete("left/over")
.await
.expect("branch_delete after adoption");
}
fn porcelain_status(dir: &Path) -> Vec<String> {
let out = Command::new("git")
.args(["status", "--porcelain=v1"])
.current_dir(dir)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.to_string())
.collect()
}
fn git_add(dir: &Path, path: &str) {
Command::new("git")
.args(["add", path])
.current_dir(dir)
.output()
.unwrap();
}
#[tokio::test]
async fn commit_only_commits_just_those_paths() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join("keep.txt"), "keep\n").unwrap();
std::fs::write(tmp.path().join("skip.txt"), "skip\n").unwrap();
let commit = git
.commit(
tmp.path(),
"only keep",
Some(&["keep.txt".to_string()]),
OtherStagedMode::Stop,
false,
)
.await
.expect("commit only=keep.txt");
assert_eq!(commit.files_changed, 1);
let listed = Command::new("git")
.args(["show", "--name-only", "--pretty=format:", "HEAD"])
.current_dir(tmp.path())
.output()
.unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert!(
listed.contains("keep.txt"),
"commit should touch keep.txt: {listed}"
);
assert!(
!listed.contains("skip.txt"),
"commit must not touch skip.txt: {listed}"
);
assert!(tmp.path().join("skip.txt").exists());
let status = porcelain_status(tmp.path());
assert!(
status.iter().any(|l| l.ends_with("skip.txt")),
"skip.txt should still be reported by status, got: {status:?}"
);
}
#[tokio::test]
async fn commit_only_stop_refuses_when_index_has_intruders() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join("other.txt"), "other\n").unwrap();
git_add(tmp.path(), "other.txt");
std::fs::write(tmp.path().join("only.txt"), "only\n").unwrap();
let err = git
.commit(
tmp.path(),
"should abort",
Some(&["only.txt".to_string()]),
OtherStagedMode::Stop,
false,
)
.await
.expect_err("stop mode must refuse when other paths are staged");
let msg = err.to_string();
assert!(
msg.contains("other_staged=stop"),
"expected other_staged=stop hint in error: {msg}"
);
assert!(
msg.contains("other.txt"),
"error should name the intruder: {msg}"
);
let head_msg = Command::new("git")
.args(["log", "-1", "--pretty=%s"])
.current_dir(tmp.path())
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&head_msg.stdout).trim(),
"initial",
"stop mode must not create a commit"
);
let status = porcelain_status(tmp.path());
assert!(
status.iter().any(|l| l.starts_with("A other.txt")),
"other.txt must remain staged, got: {status:?}"
);
}
#[tokio::test]
async fn commit_only_restage_survives_round_trip() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join("other.txt"), "other\n").unwrap();
git_add(tmp.path(), "other.txt");
std::fs::write(tmp.path().join("target.txt"), "target\n").unwrap();
let commit = git
.commit(
tmp.path(),
"only target",
Some(&["target.txt".to_string()]),
OtherStagedMode::Restage,
false,
)
.await
.expect("restage mode commits target and re-stages other");
assert_eq!(commit.files_changed, 1, "commit must only touch target.txt");
let listed = Command::new("git")
.args(["show", "--name-only", "--pretty=format:", "HEAD"])
.current_dir(tmp.path())
.output()
.unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert!(
listed.contains("target.txt"),
"commit should touch target.txt: {listed}"
);
assert!(
!listed.contains("other.txt"),
"commit must not touch other.txt: {listed}"
);
let status = porcelain_status(tmp.path());
assert!(
status.iter().any(|l| l.starts_with("A other.txt")),
"other.txt must be re-staged after restage round-trip, got: {status:?}"
);
}
#[tokio::test]
async fn commit_only_stop_ignores_other_unstaged_changes() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join("README.md"), "init\nunstaged edit\n").unwrap();
std::fs::write(tmp.path().join("target.txt"), "t\n").unwrap();
let commit = git
.commit(
tmp.path(),
"only target with unstaged noise",
Some(&["target.txt".to_string()]),
OtherStagedMode::Stop,
false,
)
.await
.expect("stop mode should still succeed when noise is only unstaged");
assert_eq!(commit.files_changed, 1);
let status = porcelain_status(tmp.path());
assert!(
status.iter().any(|l| l.starts_with(" M README.md")),
"README.md's unstaged edit must survive, got: {status:?}"
);
}
#[tokio::test]
async fn commit_skips_untracked_dotfile_not_in_gitignore() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join(".env"), "SECRET=1\n").unwrap();
std::fs::write(tmp.path().join("safe.txt"), "safe\n").unwrap();
let commit = git
.commit(
tmp.path(),
"commit with dotfile in tree",
None,
OtherStagedMode::Stop,
false,
)
.await
.expect("commit should succeed with safe.txt only");
assert_eq!(commit.files_changed, 1, "only safe.txt should be committed");
assert!(
commit.dotfile_skipped.iter().any(|p| p == ".env"),
"expected .env in dotfile_skipped, got {:?}",
commit.dotfile_skipped
);
assert!(
commit
.dotfile_warnings
.iter()
.any(|w| w.path == ".env" && !w.tracked && !w.in_gitignore),
"expected untracked+not-ignored .env warning, got {:?}",
commit.dotfile_warnings
);
let status = porcelain_status(tmp.path());
assert!(
status
.iter()
.any(|l| l.starts_with("?? ") && l.ends_with(".env")),
".env must remain untracked, got: {status:?}"
);
}
#[tokio::test]
async fn commit_silently_skips_gitignored_dotfile() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join(".gitignore"), ".env\n").unwrap();
let _ = git
.commit(
tmp.path(),
"add .gitignore",
Some(&[".gitignore".to_string()]),
OtherStagedMode::Stop,
true, )
.await
.expect("bootstrap .gitignore");
std::fs::write(tmp.path().join(".env"), "SECRET=1\n").unwrap();
std::fs::write(tmp.path().join("safe.txt"), "safe\n").unwrap();
let commit = git
.commit(
tmp.path(),
"commit with ignored .env in tree",
None,
OtherStagedMode::Stop,
false,
)
.await
.expect("commit safe.txt only");
assert_eq!(commit.files_changed, 1);
assert!(
commit.dotfile_warnings.is_empty(),
"ignored .env must not warn, got {:?}",
commit.dotfile_warnings
);
assert!(
commit.dotfile_skipped.is_empty(),
"ignored .env is already invisible to porcelain, got {:?}",
commit.dotfile_skipped
);
}
#[tokio::test]
async fn commit_warns_but_includes_tracked_dotfile_change() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join(".gitignore"), "old\n").unwrap();
let _ = git
.commit(
tmp.path(),
"add .gitignore",
Some(&[".gitignore".to_string()]),
OtherStagedMode::Stop,
true,
)
.await
.expect("bootstrap tracked .gitignore");
std::fs::write(tmp.path().join(".gitignore"), "old\nnew line\n").unwrap();
let commit = git
.commit(
tmp.path(),
"update .gitignore",
None,
OtherStagedMode::Stop,
false,
)
.await
.expect("commit tracked dotfile change");
assert_eq!(commit.files_changed, 1, ".gitignore edit must be committed");
assert!(
commit
.dotfile_warnings
.iter()
.any(|w| w.path == ".gitignore" && w.tracked),
"expected tracked .gitignore warning, got {:?}",
commit.dotfile_warnings
);
assert!(
commit.dotfile_skipped.is_empty(),
"tracked dotfile must not appear in skipped, got {:?}",
commit.dotfile_skipped
);
}
#[tokio::test]
async fn commit_force_dot_suppresses_safeguard() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join(".env"), "SECRET=1\n").unwrap();
let commit = git
.commit(
tmp.path(),
"force-commit .env",
None,
OtherStagedMode::Stop,
true,
)
.await
.expect("force_dot must let .env through");
assert_eq!(commit.files_changed, 1);
assert!(
commit.dotfile_warnings.is_empty(),
"force_dot must suppress warnings, got {:?}",
commit.dotfile_warnings
);
assert!(
commit.dotfile_skipped.is_empty(),
"force_dot must suppress skip list, got {:?}",
commit.dotfile_skipped
);
let listed = Command::new("git")
.args(["show", "--name-only", "--pretty=format:", "HEAD"])
.current_dir(tmp.path())
.output()
.unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert!(
listed.contains(".env"),
"force_dot commit should include .env: {listed}"
);
}
#[tokio::test]
async fn commit_only_drops_dotfile_from_explicit_paths() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::write(tmp.path().join(".env"), "SECRET=1\n").unwrap();
std::fs::write(tmp.path().join("safe.txt"), "safe\n").unwrap();
let commit = git
.commit(
tmp.path(),
"explicit paths with dotfile filtered",
Some(&[".env".to_string(), "safe.txt".to_string()]),
OtherStagedMode::Stop,
false,
)
.await
.expect("commit should proceed with safe.txt only");
assert_eq!(commit.files_changed, 1);
assert!(
commit.dotfile_skipped.iter().any(|p| p == ".env"),
"expected .env skipped, got {:?}",
commit.dotfile_skipped
);
let listed = Command::new("git")
.args(["show", "--name-only", "--pretty=format:", "HEAD"])
.current_dir(tmp.path())
.output()
.unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert!(listed.contains("safe.txt"));
assert!(
!listed.contains(".env"),
"safeguard-filtered .env must not land in commit: {listed}"
);
}
#[tokio::test]
async fn commit_detects_dotfile_under_untracked_nondot_dir() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
std::fs::write(tmp.path().join("data/.hidden"), "secret\n").unwrap();
std::fs::write(tmp.path().join("data/public.txt"), "ok\n").unwrap();
let commit = git
.commit(
tmp.path(),
"nested dotfile under untracked non-dot dir",
None,
OtherStagedMode::Stop,
false,
)
.await
.expect("commit should proceed with data/public.txt only");
assert!(
commit.dotfile_skipped.iter().any(|p| p == "data/.hidden"),
"expected data/.hidden in dotfile_skipped, got {:?}",
commit.dotfile_skipped
);
assert!(
commit
.dotfile_warnings
.iter()
.any(|w| w.path == "data/.hidden" && !w.tracked),
"expected untracked warning for data/.hidden, got {:?}",
commit.dotfile_warnings
);
let listed = Command::new("git")
.args(["show", "--name-only", "--pretty=format:", "HEAD"])
.current_dir(tmp.path())
.output()
.unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert!(
listed.contains("data/public.txt"),
"commit should include data/public.txt: {listed}"
);
assert!(
!listed.contains(".hidden"),
"safeguard-filtered data/.hidden must not land in commit: {listed}"
);
let status = porcelain_status(tmp.path());
assert!(
status.iter().any(|l| l.ends_with("data/.hidden")),
"data/.hidden must remain untracked, got: {status:?}"
);
}
#[tokio::test]
async fn commit_detects_nested_dotdir_component() {
let tmp = tempfile::tempdir().unwrap();
init_temp_repo(tmp.path());
let session = make_session(tmp.path());
let git = GitModule::new(session);
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
std::fs::write(tmp.path().join(".claude/CLAUDE.md"), "hi\n").unwrap();
std::fs::write(tmp.path().join("safe.txt"), "safe\n").unwrap();
let commit = git
.commit(
tmp.path(),
"nested dot-dir",
None,
OtherStagedMode::Stop,
false,
)
.await
.expect("commit safe.txt only");
assert_eq!(commit.files_changed, 1);
assert!(
commit
.dotfile_skipped
.iter()
.any(|p| p.starts_with(".claude/")),
"expected .claude/... in skipped, got {:?}",
commit.dotfile_skipped
);
}