From 40a7da33b01f87c762c0c98fac5fa4abca378d59 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sat, 5 Sep 2026 18:50:46 +0530 Subject: [PATCH 1/2] feat(tags): import and export git tags through the store fetch_tags pulls refs/tags into the odb, import peels each tag to its commit and records the change id, export writes lightweight refs walking withheld history to the nearest kept ancestor. Tag objects and messages are not preserved. GC keeps tag targets alive via reachable_changes. --- src/main.rs | 33 ++++++++- src/store.rs | 115 ++++++++++++++++++++++++++++- tests/tags_test.rs | 178 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 tests/tags_test.rs diff --git a/src/main.rs b/src/main.rs index e8d1926..1556600 100644 --- a/src/main.rs +++ b/src/main.rs @@ -413,6 +413,21 @@ 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. + for tag in store.fetch_tags(source.repo_root())? { + let sha = store.peel_tag(source.repo_root(), &tag)?; + 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 +654,22 @@ 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()? { + match store.branch_head_sha(&head_id)? { + Some(sha) => { + store.point_tag(&out_path, &tag, &sha)?; + println!("tag {tag} -> {sha}"); + } + None => { + store.log_tag_omitted(&tag, &head_id)?; + println!("tag {tag} omitted (entire history withheld)"); + } + } + } + // 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 +683,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..c50c474 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,111 @@ 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 (annotated or lightweight). + pub fn peel_tag(&self, source_repo: &Path, tag: &str) -> Result { + let output = Command::new("git") + .args(["rev-list", "-n", "1", tag]) + .current_dir(source_repo) + .output() + .context("failed to peel tag in source repository")?; + if !output.status.success() { + bail!( + "failed to resolve tag '{tag}': {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + /// 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 +1358,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 +1374,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..6ffcf5d --- /dev/null +++ b/tests/tags_test.rs @@ -0,0 +1,178 @@ +//! 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.contains("doomed"), "withheld tag leaked: {refs}"); + + 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}" + ); +} From 284bced2bc71f35fa2b5884ed26cd2b914992c92 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sat, 5 Sep 2026 19:05:23 +0530 Subject: [PATCH 2/2] fix(tags): harden review findings, skip bad tags per tag --- src/main.rs | 40 ++++++++++++++++++++++++++++------ src/store.rs | 21 +++++++++++++++--- tests/tags_test.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1556600..5d35ade 100644 --- a/src/main.rs +++ b/src/main.rs @@ -416,8 +416,20 @@ fn main() -> anyhow::Result { // 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())? { - let sha = store.peel_tag(source.repo_root(), &tag)?; + 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)?; @@ -658,15 +670,29 @@ fn main() -> anyhow::Result { // 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()? { - match store.branch_head_sha(&head_id)? { - Some(sha) => { - store.point_tag(&out_path, &tag, &sha)?; - println!("tag {tag} -> {sha}"); - } - None => { + // 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:#})"); + } } } diff --git a/src/store.rs b/src/store.rs index c50c474..23c41a7 100644 --- a/src/store.rs +++ b/src/store.rs @@ -726,22 +726,37 @@ impl Store { Ok(tags) } - /// Peel a source tag to its target commit sha (annotated or lightweight). + /// 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-list", "-n", "1", tag]) + .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}': {}", + "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<()> { diff --git a/tests/tags_test.rs b/tests/tags_test.rs index 6ffcf5d..b971883 100644 --- a/tests/tags_test.rs +++ b/tests/tags_test.rs @@ -168,7 +168,14 @@ fn test_tags_omitted_when_all_history_withheld() { ); let refs = git(&out, &["for-each-ref", "--format=%(refname)", "refs/tags"]); - assert!(!refs.contains("doomed"), "withheld tag leaked: {refs}"); + 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!( @@ -176,3 +183,48 @@ fn test_tags_omitted_when_all_history_withheld() { "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}"); +}