Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ auth = { type = "token", env = "GITHUB_TOKEN" } # remote is built in; just add
[sources.dotfiles]
host = "github" # forge remote: host + repo (or use git = "…" for a literal URL)
repo = "me/dotfiles"
branch = "main" # or tag = "...", or rev = "<sha>" (pick one)
branch = "main" # or tag = "...", or rev = "<sha>"; omit all to follow the repo's default branch
root = "modules" # re-anchor the offer at this subdirectory
include = ["editor"] # source-owned offer: include − exclude (gitignore)
exclude = ["**/*.bak"]
Expand Down
5 changes: 4 additions & 1 deletion src/config/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ impl ParsedSource {
} else if let Some(branch) = &self.branch {
Refspec::Branch(branch.clone())
} else {
Refspec::Branch("main".into())
Refspec::Default
}
}

Expand Down Expand Up @@ -555,6 +555,8 @@ pub enum Refspec {
Branch(String),
Tag(String),
Rev(String),
/// No ref named in config: follow the remote's advertised default branch.
Default,
/// A url source has no git ref; its mirror lives at refs/heads/phora.
None,
}
Expand All @@ -563,6 +565,7 @@ impl std::fmt::Display for Refspec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Branch(s) | Self::Tag(s) | Self::Rev(s) => write!(f, "{s}"),
Self::Default => write!(f, "default"),
Self::None => write!(f, ""),
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/config/target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ impl SourceFields for Source {
} else if let Some(branch) = &self.branch {
Refspec::Branch(branch.clone())
} else {
Refspec::Branch("main".into())
Refspec::Default
}
}
}
Expand Down
16 changes: 8 additions & 8 deletions src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,11 +403,11 @@ auth = { type = "token", env = "GITHUB_TOKEN" }
// PAM-002: refspec priority and export policy defaults

#[test]
fn refspec_defaults_to_main_branch() {
assert!(matches!(
source(None, None, None).refspec(),
Refspec::Branch(b) if b == "main"
));
fn refspec_defaults_to_remote_default_branch() {
assert!(
matches!(source(None, None, None).refspec(), Refspec::Default),
"an unspecified ref must defer to the remote's default branch, not assume `main`"
);
}

#[test]
Expand Down Expand Up @@ -2584,9 +2584,9 @@ fn refspec_still_branch_tag_rev_for_git_source() {

let default = config_source("g", "git = \"https://x/y.git\"\n");
assert!(
matches!(default.refspec(), Refspec::Branch(b) if b == "main"),
"a git source with no explicit ref must still default to Branch(\"main\"); \
Refspec::None must not leak into git sources"
matches!(default.refspec(), Refspec::Default),
"a git source with no explicit ref must default to the remote's default branch; \
Refspec::None (url-only) must not leak into git sources"
);
}

Expand Down
1 change: 1 addition & 0 deletions src/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ pub fn encode_ref(r: &Refspec) -> String {
Refspec::Branch(s) => format!("branch:{s}"),
Refspec::Tag(s) => format!("tag:{s}"),
Refspec::Rev(s) => format!("rev:{s}"),
Refspec::Default => "default".to_owned(),
Refspec::None => "url".to_owned(),
}
}
Expand Down
75 changes: 74 additions & 1 deletion src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ fn shallow_ref_name(refspec: &Refspec) -> Option<String> {
match refspec {
Refspec::Branch(name) => Some(format!("refs/heads/{name}")),
Refspec::Tag(name) => Some(format!("refs/tags/{name}")),
Refspec::Rev(_) | Refspec::None => None,
Refspec::Rev(_) | Refspec::Default | Refspec::None => None,
}
}

Expand All @@ -425,6 +425,9 @@ fn resolve_in(repo: &gix::Repository, source: &SourceName, refspec: &Refspec) ->
repo.find_commit(oid)
.map_err(|e| SourceError::Source(format!("rev {rev} in {source}: {e}")))?
}
Refspec::Default => repo
.head_commit()
.map_err(|e| SourceError::Source(format!("default branch (HEAD) in {source}: {e}")))?,
Refspec::None => {
return Err(SourceError::Source(format!(
"source {source}: git backend cannot resolve a url source's empty refspec"
Expand Down Expand Up @@ -1862,6 +1865,76 @@ mod tests {
);
}

#[expect(
clippy::unwrap_used,
reason = "fixture setup fails loudly; git CLI is assumed present"
)]
fn build_trunk_default_repo() -> (TempDir, TempDir, GitBackend, String, String) {
let src = TempDir::new().unwrap();
let p = src.path();
run_git(p, &["init", "-b", "trunk", "."]);
run_git(p, &["config", "user.email", "test@example.com"]);
run_git(p, &["config", "user.name", "Test"]);
std::fs::write(p.join("README.md"), b"on trunk\n").unwrap();
run_git(p, &["add", "README.md"]);
run_git(p, &["commit", "-m", "initial"]);
let trunk_sha = String::from_utf8(run_git(p, &["rev-parse", "HEAD"]).stdout)
.unwrap()
.trim()
.to_string();

let git_dir = TempDir::new().unwrap();
let backend = GitBackend::new(git_dir.path().to_path_buf());
let url = p.to_string_lossy().into_owned();
(src, git_dir, backend, url, trunk_sha)
}

#[test]
fn resolve_default_follows_remote_default_branch_when_not_main() {
let (_src, _git_dir, backend, url, trunk_sha) = build_trunk_default_repo();

backend
.fetch(&sn("src"), &url)
.expect("fetch a repo whose default branch is trunk");

let resolved = backend
.resolve(&sn("src"), &url, &Refspec::Default)
.expect("Default must resolve against a repo that has no `main` branch");

assert_eq!(
resolved, trunk_sha,
"an unspecified ref must follow the repo's actual default branch (trunk), \
not assume `main` — which does not exist here"
);
}

#[test]
fn resolve_default_survives_an_incremental_fetch() {
let (src, _git_dir, backend, url, _first_sha) = build_trunk_default_repo();
backend.fetch(&sn("src"), &url).expect("first fetch");

std::fs::write(src.path().join("SECOND.md"), b"more\n").expect("write");
run_git(src.path(), &["add", "SECOND.md"]);
run_git(src.path(), &["commit", "-m", "second"]);
let advanced = String::from_utf8(run_git(src.path(), &["rev-parse", "HEAD"]).stdout)
.expect("utf8")
.trim()
.to_string();

backend
.fetch(&sn("src"), &url)
.expect("incremental fetch on the existing mirror");
let resolved = backend
.resolve(&sn("src"), &url, &Refspec::Default)
.expect("Default still resolves after an incremental fetch");

assert_eq!(
resolved, advanced,
"Default must track the default branch tip across an incremental fetch, \
not a stale HEAD"
);
}

#[test]
fn file_diff_between_reads_both_commits_and_reports_the_changed_path() {
let fixture = build_git_fixture();
Expand Down