diff --git a/src/main.rs b/src/main.rs index e8d1926..5d35ade 100644 --- a/src/main.rs +++ b/src/main.rs @@ -413,6 +413,33 @@ fn main() -> anyhow::Result { store.set_ref(branch, &head_id)?; println!("branch {branch}: {} change(s) imported", commits.len()); } + + // Tags resolve against the commits just imported: annotated tags + // peel to their target commit, tag objects are not preserved. + // One bad tag must not sink the import, so every per-tag step + // skips with a warning instead of bailing. + for tag in store.fetch_tags(source.repo_root())? { + if !Store::valid_tag_ref(&tag) { + eprintln!("tag {tag}: invalid refname, skipped"); + continue; + } + let sha = match store.peel_tag(source.repo_root(), &tag) { + Ok(sha) => sha, + Err(e) => { + eprintln!("tag {tag}: skipped ({e:#})"); + continue; + } + }; + match store.change_for_commit(&sha)? { + Some(id) => { + store.set_tag(&tag, &id)?; + println!("tag {tag}: imported"); + } + None => { + eprintln!("tag {tag}: target commit not imported, skipped"); + } + } + } Ok(std::process::ExitCode::SUCCESS) } Commands::Record { message, branch } => { @@ -639,6 +666,36 @@ fn main() -> anyhow::Result { } } + // Tags follow their target change through filtering: a tag whose + // target was withheld walks up to the nearest kept ancestor, and + // one with no kept history at all is omitted with a log entry. + for (tag, head_id) in store.tags()? { + // One corrupt tag must not sink the export: per-tag failures + // land in the audit log like any other omission. + if !Store::valid_tag_ref(&tag) { + store.log_tag_omitted(&tag, &head_id)?; + println!("tag {tag} omitted (invalid refname)"); + continue; + } + match store.branch_head_sha(&head_id) { + Ok(Some(sha)) => match store.point_tag(&out_path, &tag, &sha) { + Ok(()) => println!("tag {tag} -> {sha}"), + Err(e) => { + store.log_tag_omitted(&tag, &head_id)?; + println!("tag {tag} omitted ({e:#})"); + } + }, + Ok(None) => { + store.log_tag_omitted(&tag, &head_id)?; + println!("tag {tag} omitted (entire history withheld)"); + } + Err(e) => { + store.log_tag_omitted(&tag, &head_id)?; + println!("tag {tag} omitted ({e:#})"); + } + } + } + // Point HEAD at the first exported branch so `git log` works immediately, // and populate the working tree files so the exported repo is ready to inspect. if let Some(first_branch) = first_exported_branch { @@ -652,7 +709,7 @@ fn main() -> anyhow::Result { } println!( - "exported to {out}\nnext: cd {out} && git remote add origin && git push -u origin --all" + "exported to {out}\nnext: cd {out} && git remote add origin && git push -u origin --all && git push --tags" ); Ok(std::process::ExitCode::SUCCESS) } diff --git a/src/store.rs b/src/store.rs index 0732d9c..23c41a7 100644 --- a/src/store.rs +++ b/src/store.rs @@ -27,6 +27,7 @@ const OBJECTS_DIR: &str = "objects.git"; const CHANGES_DIR: &str = "changes"; const MAP_DIR: &str = "map"; const REFS_DIR: &str = "refs"; +const TAGS_DIR: &str = "tags"; const EXPORT_LOG: &str = "export-log.jsonl"; /// Git's well-known empty tree; used to diff root commits against nothing. const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; @@ -109,6 +110,7 @@ impl Store { std::fs::create_dir_all(oot.join(CHANGES_DIR))?; std::fs::create_dir_all(oot.join(MAP_DIR))?; std::fs::create_dir_all(oot.join(REFS_DIR))?; + std::fs::create_dir_all(oot.join(TAGS_DIR))?; std::fs::create_dir_all(oot.join("export"))?; std::fs::write(oot.join("HEAD"), "ref: refs/heads/main\n")?; run(Command::new("git") @@ -692,6 +694,126 @@ impl Store { Ok(Some(std::fs::read_to_string(f)?.trim().to_string())) } + /// Fetch all tags from a source repository into the store's odb and + /// return their short names. Annotated tags peel to their target commit + /// on import; tag objects and messages are not preserved, export writes + /// lightweight refs. Branch objects must already be fetched. + pub fn fetch_tags(&self, source_repo: &Path) -> Result> { + let output = Command::new("git") + .args(["for-each-ref", "--format=%(refname:short)", "refs/tags"]) + .current_dir(source_repo) + .output() + .context("failed to list tags in source repository")?; + if !output.status.success() { + bail!("not a valid git repository: {}", source_repo.display()); + } + let tags: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(); + + if !tags.is_empty() { + run(Command::new("git") + .args(["--git-dir"]) + .arg(self.git_dir()) + .args(["fetch", "--quiet"]) + .arg(source_repo) + .args(["+refs/tags/*:refs/oot/source-tags/*"]))?; + } + + Ok(tags) + } + + /// Peel a source tag to its target commit sha. The full ref path plus + /// `^{commit}` resolves annotated tags through to the commit, keeps a + /// tag named like a branch from misresolving, and fails on non-commit + /// targets instead of poisoning the commit map. + pub fn peel_tag(&self, source_repo: &Path, tag: &str) -> Result { + let reference = format!("refs/tags/{tag}^{{commit}}"); + let output = Command::new("git") + .args(["rev-parse", "--verify", "--end-of-options", &reference]) + .current_dir(source_repo) + .output() + .context("failed to peel tag in source repository")?; + if !output.status.success() { + bail!( + "failed to resolve tag '{tag}' to a commit: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + /// Whether `tag` is a safe refname for `refs/tags/`. + pub fn valid_tag_ref(tag: &str) -> bool { + if tag.is_empty() { + return false; + } + Command::new("git") + .args(["check-ref-format", &format!("refs/tags/{tag}")]) + .output() + .is_ok_and(|o| o.status.success()) + } + + /// Record the head change id for a tag. Names share the branch + /// percent-encoding so `v1/rc1` survives as one file. + pub fn set_tag(&self, tag: &str, id: &str) -> Result<()> { + std::fs::create_dir_all(self.root.join(TAGS_DIR))?; + let safe = encode_branch(tag); + std::fs::write(self.root.join(TAGS_DIR).join(safe), id)?; + Ok(()) + } + + /// Read all recorded tags as (tag, head change id). + pub fn tags(&self) -> Result> { + let dir = self.root.join(TAGS_DIR); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + if !entry.path().is_file() { + continue; + } + let raw = entry.file_name().to_string_lossy().to_string(); + let name = decode_branch(&raw); + let id = std::fs::read_to_string(entry.path())?.trim().to_string(); + out.push((name, id)); + } + out.sort(); + Ok(out) + } + + /// Record that a tag was omitted from an export because its target + /// history was entirely withheld. + pub fn log_tag_omitted(&self, tag: &str, head_id: &str) -> Result<()> { + let entry = serde_json::json!({ + "epoch": now_epoch(), + "event": "tag-omitted", + "tag": tag, + "change": head_id, + }); + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(self.root.join(EXPORT_LOG))?; + writeln!(f, "{entry}")?; + Ok(()) + } + + /// Update a tag ref in the exported repository to point at `sha`. + pub fn point_tag(&self, out_repo: &Path, tag: &str, sha: &str) -> Result<()> { + run(Command::new("git") + .args(["--git-dir"]) + .arg(out_repo.join(".git")) + .args(["update-ref", &format!("refs/tags/{tag}"), sha]))?; + Ok(()) + } + /// Replay every indexed change into `out_repo` (which must be an /// initialized git repository) as real commits. The store's odb is /// attached via `GIT_ALTERNATE_OBJECT_DIRECTORIES`, so trees and blobs are @@ -1251,8 +1373,9 @@ impl Store { Ok(Some(std::fs::read_to_string(f)?.trim().to_string())) } - /// Discover all change IDs reachable from the given roots and all branch refs. - /// Fails loudly if any reachable change record cannot be read. + /// Discover all change IDs reachable from the given roots and all + /// branch and tag refs. Fails loudly if any reachable change record + /// cannot be read. pub fn reachable_changes(&self, extra_roots: &[String]) -> Result> { let mut reachable = HashSet::new(); let mut queue = std::collections::VecDeque::new(); @@ -1266,6 +1389,9 @@ impl Store { for (_, head_id) in self.refs()? { queue.push_back(head_id); } + for (_, head_id) in self.tags()? { + queue.push_back(head_id); + } while let Some(id) = queue.pop_front() { if reachable.insert(id.clone()) { diff --git a/tests/tags_test.rs b/tests/tags_test.rs new file mode 100644 index 0000000..b971883 --- /dev/null +++ b/tests/tags_test.rs @@ -0,0 +1,230 @@ +//! Tags round-trip: git tags -> Oot store -> exported git repo. +//! +//! Import peels every tag (lightweight or annotated) to its target commit +//! and records the change id. Export writes lightweight `refs/tags/*` refs, +//! walking withheld history to the nearest kept ancestor like branches do. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_oot") +} + +fn git(repo: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_AUTHOR_NAME", "Kriday") + .env("GIT_AUTHOR_EMAIL", "k@oot.dev") + .env("GIT_COMMITTER_NAME", "Kriday") + .env("GIT_COMMITTER_EMAIL", "k@oot.dev") + .output() + .expect("git should run"); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +/// Runs an oot subcommand; returns (success, stdout+stderr). +fn oot(args: &[&str], cwd: &Path) -> (bool, String) { + let o = Command::new(bin()) + .args(args) + .current_dir(cwd) + .output() + .expect("oot binary should run"); + ( + o.status.success(), + format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ), + ) +} + +/// Two-commit history with a lightweight tag, an annotated tag, and a +/// slashed tag name. +fn build_tagged_source(path: &PathBuf) { + std::fs::create_dir_all(path).unwrap(); + git(path, &["init", "--quiet", "-b", "main"]); + std::fs::write(path.join("a.txt"), "v1\n").unwrap(); + git(path, &["add", "."]); + git(path, &["commit", "-m", "first"]); + git(path, &["tag", "v0.1"]); + std::fs::write(path.join("a.txt"), "v2\n").unwrap(); + git(path, &["add", "."]); + git(path, &["commit", "-m", "second"]); + git(path, &["tag", "-a", "v0.2", "-m", "second release"]); + git(path, &["tag", "release/rc1"]); +} + +#[test] +fn test_tags_roundtrip_byte_identical() { + let tmp = std::env::temp_dir().join(format!("oot-tags-rt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let src = tmp.join("src"); + let proj = tmp.join("proj"); + let out = tmp.join("out"); + std::fs::create_dir_all(&proj).unwrap(); + build_tagged_source(&src); + + assert!(oot(&["init"], &proj).0); + let (ok, msg) = oot(&["import", "--repo", src.to_str().unwrap()], &proj); + assert!(ok, "import failed: {msg}"); + assert!(msg.contains("tag v0.1"), "import should report tags: {msg}"); + + let (ok, msg) = oot(&["export", "--out", out.to_str().unwrap()], &proj); + assert!(ok, "export failed: {msg}"); + + // Every tag survived as a lightweight ref to a commit. + for tag in ["v0.1", "v0.2", "release/rc1"] { + let kind = git(&out, &["cat-file", "-t", &format!("refs/tags/{tag}")]); + assert_eq!(kind, "commit", "exported {tag} should be lightweight"); + // Identity fast path: untouched history keeps original shas. + let want = git(&src, &["rev-list", "-n", "1", tag]); + let got = git(&out, &["rev-parse", &format!("refs/tags/{tag}")]); + assert_eq!(got, want, "tag {tag} moved"); + } +} + +/// Base commit, secret commit, clean follow-up. A tag on the withheld +/// secret commit falls back to the nearest kept ancestor (the base). +#[test] +fn test_tags_follow_withheld_history_to_kept_ancestor() { + let tmp = std::env::temp_dir().join(format!("oot-tags-fb-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let src = tmp.join("src"); + let proj = tmp.join("proj"); + let out = tmp.join("out"); + std::fs::create_dir_all(&proj).unwrap(); + + std::fs::create_dir_all(&src).unwrap(); + git(&src, &["init", "--quiet", "-b", "main"]); + std::fs::write(src.join("README.md"), "v1\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "base"]); + let base = git(&src, &["rev-parse", "main"]); + std::fs::write(src.join(".env"), "API_KEY=x\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "secret"]); + git(&src, &["tag", "on-secret"]); + std::fs::write(src.join("README.md"), "v2\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "followup"]); + + std::fs::write( + proj.join("visibility.toml"), + "private_paths = [\".env\"]\nprivate_branches = []\n", + ) + .unwrap(); + + assert!(oot(&["init"], &proj).0); + let (ok, msg) = oot(&["import", "--repo", src.to_str().unwrap()], &proj); + assert!(ok, "import failed: {msg}"); + let (ok, msg) = oot(&["export", "--out", out.to_str().unwrap()], &proj); + assert!(ok, "export failed: {msg}"); + + let got = git(&out, &["rev-parse", "refs/tags/on-secret"]); + assert_eq!(got, base, "withheld tag should fall back to base commit"); +} + +/// A tag whose entire history was withheld is omitted with a log entry. +#[test] +fn test_tags_omitted_when_all_history_withheld() { + let tmp = std::env::temp_dir().join(format!("oot-tags-om-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let src = tmp.join("src"); + let proj = tmp.join("proj"); + let out = tmp.join("out"); + std::fs::create_dir_all(&proj).unwrap(); + + std::fs::create_dir_all(&src).unwrap(); + git(&src, &["init", "--quiet", "-b", "main"]); + std::fs::write(src.join(".env"), "API_KEY=x\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "only secret"]); + git(&src, &["tag", "doomed"]); + + std::fs::write( + proj.join("visibility.toml"), + "private_paths = [\".env\"]\nprivate_branches = []\n", + ) + .unwrap(); + + assert!(oot(&["init"], &proj).0); + let (ok, msg) = oot(&["import", "--repo", src.to_str().unwrap()], &proj); + assert!(ok, "import failed: {msg}"); + let (ok, msg) = oot(&["export", "--out", out.to_str().unwrap()], &proj); + assert!(ok, "export failed: {msg}"); + assert!( + msg.contains("omitted"), + "export should report omission: {msg}" + ); + + let refs = git(&out, &["for-each-ref", "--format=%(refname)", "refs/tags"]); + assert!(refs.is_empty(), "withheld tag leaked: {refs}"); + let probe = Command::new("git") + .arg("-C") + .arg(&out) + .args(["rev-parse", "--verify", "--quiet", "refs/tags/doomed"]) + .output() + .unwrap(); + assert!(!probe.status.success(), "withheld tag resolves"); + + let log = std::fs::read_to_string(proj.join(".oot").join("export-log.jsonl")).unwrap(); + assert!( + log.contains("tag-omitted") && log.contains("doomed"), + "omission must be audited: {log}" + ); +} + +/// A tag pointing at a blob (not a commit) is skipped with a warning, the +/// rest of the import succeeds, and re-importing is idempotent. +#[test] +fn test_tags_skip_non_commit_targets_and_reimport() { + let tmp = std::env::temp_dir().join(format!("oot-tags-sk-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let src = tmp.join("src"); + let proj = tmp.join("proj"); + let out = tmp.join("out"); + std::fs::create_dir_all(&proj).unwrap(); + + std::fs::create_dir_all(&src).unwrap(); + git(&src, &["init", "--quiet", "-b", "main"]); + std::fs::write(src.join("a.txt"), "v1\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "first"]); + git(&src, &["tag", "good"]); + let blob = git(&src, &["hash-object", "-w", "a.txt"]); + git(&src, &["tag", "notacommit", &blob]); + + assert!(oot(&["init"], &proj).0); + let (ok, msg) = oot(&["import", "--repo", src.to_str().unwrap()], &proj); + assert!(ok, "import failed: {msg}"); + assert!( + msg.contains("tag good: imported"), + "good tag missing: {msg}" + ); + assert!( + msg.contains("notacommit") && msg.contains("skipped"), + "blob tag should skip with a warning: {msg}" + ); + + // Second import over the same store succeeds and changes nothing. + let (ok, msg) = oot(&["import", "--repo", src.to_str().unwrap()], &proj); + assert!(ok, "reimport failed: {msg}"); + + let (ok, msg) = oot(&["export", "--out", out.to_str().unwrap()], &proj); + assert!(ok, "export failed: {msg}"); + let refs = git( + &out, + &["for-each-ref", "--format=%(refname:short)", "refs/tags"], + ); + assert_eq!(refs, "good", "only the commit tag should export: {refs}"); +}