use std::path::{Path, PathBuf};
const KEY_V2_GIT_PREFIX: &[u8] = b"newt-workspace-key:v2:git";
const KEY_V2_PATH_PREFIX: &[u8] = b"newt-workspace-key:v2:path";
pub fn workspace_key_v2(dir: impl AsRef<Path>) -> anyhow::Result<String> {
let canonical = std::fs::canonicalize(dir.as_ref())?;
if let Some((url, branch)) = git_identity(&canonical) {
return Ok(domain_hash(
KEY_V2_GIT_PREFIX,
&[url.as_bytes(), branch.as_bytes()],
));
}
let normalized = canonical.to_string_lossy().replace('\\', "/");
Ok(domain_hash(KEY_V2_PATH_PREFIX, &[normalized.as_bytes()]))
}
fn git_identity(start: &Path) -> Option<(String, String)> {
let git_dir = discover_git_dir(start)?;
let branch = head_branch(&git_dir.join("HEAD"))?;
let url = origin_url(&config_path(&git_dir))?;
Some((url, branch))
}
fn discover_git_dir(start: &Path) -> Option<PathBuf> {
let mut current = Some(start);
while let Some(dir) = current {
let dot_git = dir.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
if dot_git.is_file() {
return resolve_gitdir_file(&dot_git, dir);
}
current = dir.parent();
}
None
}
fn resolve_gitdir_file(dot_git: &Path, containing_dir: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(dot_git).ok()?;
let target = text.lines().next()?.trim().strip_prefix("gitdir:")?.trim();
if target.is_empty() {
return None;
}
let resolved = containing_dir.join(target); Some(std::fs::canonicalize(&resolved).unwrap_or(resolved))
}
fn config_path(git_dir: &Path) -> PathBuf {
if let Ok(text) = std::fs::read_to_string(git_dir.join("commondir")) {
let rel = text.trim();
if !rel.is_empty() {
return git_dir.join(rel).join("config");
}
}
git_dir.join("config")
}
fn head_branch(head: &Path) -> Option<String> {
let text = std::fs::read_to_string(head).ok()?;
let branch = text
.lines()
.next()?
.trim()
.strip_prefix("ref:")?
.trim()
.strip_prefix("refs/heads/")?;
if branch.is_empty() {
None
} else {
Some(branch.to_string())
}
}
fn origin_url(config: &Path) -> Option<String> {
let text = std::fs::read_to_string(config).ok()?;
let mut in_origin = false;
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') {
in_origin = is_origin_section_header(line);
continue;
}
if !in_origin {
continue;
}
if let Some((key, value)) = line.split_once('=') {
if key.trim().eq_ignore_ascii_case("url") {
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.unwrap_or(value);
if !value.is_empty() {
return Some(value.to_string());
}
}
}
}
None
}
fn is_origin_section_header(line: &str) -> bool {
let Some(inner) = line
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
else {
return false;
};
let inner = inner.trim();
let Some((section, subsection)) = inner.split_once(char::is_whitespace) else {
return false;
};
section.eq_ignore_ascii_case("remote") && subsection.trim() == "\"origin\""
}
fn domain_hash(domain: &[u8], fields: &[&[u8]]) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(domain);
for field in fields {
hasher.update(&(field.len() as u64).to_le_bytes());
hasher.update(field);
}
hasher.finalize().to_hex().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_repo(dir: &Path, url: Option<&str>, head: &str) {
let git = dir.join(".git");
std::fs::create_dir_all(&git).unwrap();
std::fs::write(git.join("HEAD"), head).unwrap();
let mut config = String::from("[core]\n\trepositoryformatversion = 0\n");
if let Some(url) = url {
config.push_str(&format!("[remote \"origin\"]\n\turl = {url}\n"));
}
std::fs::write(git.join("config"), config).unwrap();
}
#[test]
fn same_remote_and_branch_derive_the_same_key_across_paths() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
let url = Some("https://example.invalid/owner/project.git");
fake_repo(a.path(), url, "ref: refs/heads/main\n");
fake_repo(b.path(), url, "ref: refs/heads/main\n");
assert_eq!(
workspace_key_v2(a.path()).unwrap(),
workspace_key_v2(b.path()).unwrap(),
"two checkouts of one logical project must share a key"
);
}
#[test]
fn branch_change_derives_a_different_key() {
let dir = tempfile::tempdir().unwrap();
let url = Some("https://example.invalid/owner/project.git");
fake_repo(dir.path(), url, "ref: refs/heads/main\n");
let on_main = workspace_key_v2(dir.path()).unwrap();
std::fs::write(dir.path().join(".git/HEAD"), "ref: refs/heads/feat/other\n").unwrap();
let on_feat = workspace_key_v2(dir.path()).unwrap();
assert_ne!(
on_main, on_feat,
"a branch is a different conversation context (module docs)"
);
}
#[test]
fn subdirectory_of_a_repo_shares_the_repo_key() {
let dir = tempfile::tempdir().unwrap();
fake_repo(
dir.path(),
Some("https://example.invalid/o/p.git"),
"ref: refs/heads/main\n",
);
let sub = dir.path().join("src/deep");
std::fs::create_dir_all(&sub).unwrap();
assert_eq!(
workspace_key_v2(dir.path()).unwrap(),
workspace_key_v2(&sub).unwrap(),
"discovery walks up to the repo, like `git rev-parse`"
);
}
#[test]
fn non_git_dir_is_path_keyed_stable_and_distinct() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
let first = workspace_key_v2(a.path()).unwrap();
let again = workspace_key_v2(a.path()).unwrap();
let other = workspace_key_v2(b.path()).unwrap();
assert_eq!(first, again, "path key must be stable for the same dir");
assert_ne!(first, other, "different dirs must derive different keys");
let canonical = std::fs::canonicalize(a.path()).unwrap();
let normalized = canonical.to_string_lossy().replace('\\', "/");
assert_eq!(
first,
domain_hash(KEY_V2_PATH_PREFIX, &[normalized.as_bytes()])
);
}
#[test]
fn detached_head_falls_back_to_path_keying() {
let dir = tempfile::tempdir().unwrap();
fake_repo(
dir.path(),
Some("https://example.invalid/o/p.git"),
"a94a8fe5ccb19ba61c4c0873d391e987982fbbd3\n",
);
let canonical = std::fs::canonicalize(dir.path()).unwrap();
let normalized = canonical.to_string_lossy().replace('\\', "/");
assert_eq!(
workspace_key_v2(dir.path()).unwrap(),
domain_hash(KEY_V2_PATH_PREFIX, &[normalized.as_bytes()]),
"detached HEAD must be path-keyed (module docs choice)"
);
}
#[test]
fn repo_without_origin_remote_falls_back_to_path_keying() {
let dir = tempfile::tempdir().unwrap();
fake_repo(dir.path(), None, "ref: refs/heads/main\n");
let canonical = std::fs::canonicalize(dir.path()).unwrap();
let normalized = canonical.to_string_lossy().replace('\\', "/");
assert_eq!(
workspace_key_v2(dir.path()).unwrap(),
domain_hash(KEY_V2_PATH_PREFIX, &[normalized.as_bytes()])
);
}
#[test]
fn worktree_gitfile_resolves_through_gitdir_and_commondir() {
let main = tempfile::tempdir().unwrap();
fake_repo(
main.path(),
Some("ssh://git@example.invalid/o/p.git"),
"ref: refs/heads/main\n",
);
let wt_gitdir = main.path().join(".git/worktrees/wt1");
std::fs::create_dir_all(&wt_gitdir).unwrap();
std::fs::write(wt_gitdir.join("HEAD"), "ref: refs/heads/feat/wt\n").unwrap();
std::fs::write(wt_gitdir.join("commondir"), "../..\n").unwrap();
let wt = tempfile::tempdir().unwrap();
std::fs::write(
wt.path().join(".git"),
format!("gitdir: {}\n", wt_gitdir.display()),
)
.unwrap();
let twin = tempfile::tempdir().unwrap();
fake_repo(
twin.path(),
Some("ssh://git@example.invalid/o/p.git"),
"ref: refs/heads/feat/wt\n",
);
assert_eq!(
workspace_key_v2(wt.path()).unwrap(),
workspace_key_v2(twin.path()).unwrap(),
"worktree must key by (shared config URL, per-worktree branch)"
);
}
#[test]
fn git_and_path_domains_never_collide() {
assert_ne!(
domain_hash(KEY_V2_GIT_PREFIX, &[b"u", b"b"]),
domain_hash(KEY_V2_PATH_PREFIX, &[b"u", b"b"])
);
assert_ne!(
domain_hash(KEY_V2_GIT_PREFIX, &[b"ab", b"c"]),
domain_hash(KEY_V2_GIT_PREFIX, &[b"a", b"bc"])
);
}
#[test]
fn origin_section_header_matching_is_git_faithful() {
assert!(is_origin_section_header("[remote \"origin\"]"));
assert!(is_origin_section_header("[REMOTE \"origin\"]")); assert!(!is_origin_section_header("[remote \"Origin\"]")); assert!(!is_origin_section_header("[remote \"upstream\"]"));
assert!(!is_origin_section_header("[branch \"origin\"]"));
assert!(!is_origin_section_header("[remote]"));
assert!(!is_origin_section_header("not a header"));
}
#[test]
fn origin_url_parses_comments_quotes_and_key_case() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config");
std::fs::write(
&config,
"# global comment\n\
[remote \"upstream\"]\n\
\turl = https://example.invalid/wrong.git\n\
[remote \"origin\"]\n\
\t; another comment\n\
\tfetch = +refs/heads/*:refs/remotes/origin/*\n\
\tURL = \"https://example.invalid/right.git\"\n",
)
.unwrap();
assert_eq!(
origin_url(&config).as_deref(),
Some("https://example.invalid/right.git")
);
}
#[test]
fn head_branch_handles_refs_outside_heads_and_garbage() {
let dir = tempfile::tempdir().unwrap();
let head = dir.path().join("HEAD");
std::fs::write(&head, "ref: refs/heads/feat/x\n").unwrap();
assert_eq!(head_branch(&head).as_deref(), Some("feat/x"));
std::fs::write(&head, "ref: refs/tags/v1\n").unwrap();
assert_eq!(head_branch(&head), None, "non-branch ref is not a branch");
std::fs::write(&head, "ref: refs/heads/\n").unwrap();
assert_eq!(head_branch(&head), None, "empty branch name is invalid");
std::fs::write(&head, "").unwrap();
assert_eq!(head_branch(&head), None);
assert_eq!(head_branch(&dir.path().join("missing")), None);
}
#[test]
fn corrupt_gitdir_pointer_degrades_to_path_keying() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".git"), "this is not a gitdir pointer").unwrap();
let canonical = std::fs::canonicalize(dir.path()).unwrap();
let normalized = canonical.to_string_lossy().replace('\\', "/");
assert_eq!(
workspace_key_v2(dir.path()).unwrap(),
domain_hash(KEY_V2_PATH_PREFIX, &[normalized.as_bytes()]),
"a broken repo must never block key derivation"
);
}
}