diff options
Diffstat (limited to 'src/cmd/patch')
-rw-r--r-- | src/cmd/patch/create.rs | 483 | ||||
-rw-r--r-- | src/cmd/patch/prepare.rs | 615 |
2 files changed, 1098 insertions, 0 deletions
diff --git a/src/cmd/patch/create.rs b/src/cmd/patch/create.rs new file mode 100644 index 0000000..7527364 --- /dev/null +++ b/src/cmd/patch/create.rs @@ -0,0 +1,483 @@ +// Copyright © 2022 Kim Altintop <kim@eagain.io> +// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception + +use std::{ + borrow::Cow, + collections::BTreeMap, + env, + path::PathBuf, +}; + +use anyhow::anyhow; +use clap::ValueHint; +use globset::{ + GlobSet, + GlobSetBuilder, +}; +use once_cell::sync::Lazy; +use url::Url; + +use super::prepare; +use crate::{ + cfg, + cmd::{ + self, + ui::{ + self, + debug, + info, + }, + util::args::IdSearchPath, + Aborted, + }, + git::{ + self, + Refname, + }, + metadata::IdentityId, + patches::{ + self, + iter, + DropHead, + Topic, + TrackingBranch, + GLOB_IT_BUNDLES, + GLOB_IT_IDS, + GLOB_IT_TOPICS, + REF_HEADS_PATCHES, + REF_IT_BUNDLES, + REF_IT_PATCHES, + REF_IT_SEEN, + }, + paths, +}; + +#[derive(Debug, clap::Args)] +pub struct Common { + /// Path to the drop repository + #[clap(from_global)] + git_dir: PathBuf, + /// Path to the source repository + /// + /// If set, the patch bundle will be created from objects residing in an + /// external repository. The main use case for this is to allow a bare + /// drop to pull in checkpoints from a local repo with a regular layout + /// (ie. non it-aware). + #[clap( + long = "source-dir", + alias = "src-dir", + value_parser, + value_name = "DIR", + value_hint = ValueHint::DirPath, + )] + src_dir: Option<PathBuf>, + /// Identity to assume + /// + /// If not set as an option nor in the environment, the value of `it.id` in + /// the git config is tried. + #[clap(short = 'I', long = "identity", value_name = "ID", env = "IT_ID")] + id: Option<IdentityId>, + /// A list of paths to search for identity repositories + #[clap( + long, + value_parser, + value_name = "PATH", + env = "IT_ID_PATH", + default_value_t, + value_hint = ValueHint::DirPath, + )] + id_path: IdSearchPath, + /// The directory where to write the bundle to + /// + /// Unless this is an absolute path, it is treated as relative to $GIT_DIR. + #[clap( + long, + value_parser, + value_name = "DIR", + default_value_os_t = paths::bundles().to_owned(), + value_hint = ValueHint::DirPath, + )] + bundle_dir: PathBuf, + /// IPFS API to publish the patch bundle to + /// + /// Currently has no effect when submitting a patch to a remote drop. When + /// running `ipfs daemon`, the default API address is 'http://127.0.0.1:5001'. + #[clap( + long, + value_parser, + value_name = "URL", + value_hint = ValueHint::Url, + )] + ipfs_api: Option<Url>, + /// Additional identities to include, eg. to allow commit verification + #[clap(long = "add-id", value_parser, value_name = "ID")] + ids: Vec<IdentityId>, + /// Message to attach to the patch (cover letter, comment) + /// + /// If not set, $EDITOR will be invoked to author one. + #[clap(short, long, value_parser, value_name = "STRING")] + message: Option<String>, + /// Create the patch, but stop short of submitting / recording it + #[clap(long, value_parser)] + dry_run: bool, +} + +#[derive(Debug, clap::Args)] +pub struct Remote { + /// Url to submit the patch to + /// + /// Usually one of the alternates from the drop metadata. If not set, + /// GIT_DIR is assumed to contain a drop with which the patch can be + /// recorded without any network access. + #[clap(long, visible_alias = "submit-to", value_parser, value_name = "URL")] + url: Url, + /// Refname of the drop to record the patch with + /// + /// We need to pick a local (remote-tracking) drop history in order to + /// compute delta bases for the patch. The value is interpreted + /// according to "DWIM" rules, i.e. shorthand forms like 'it/patches', + /// 'origin/patches' are attempted to be resolved. + #[clap(long = "drop", value_parser, value_name = "STRING")] + drop_ref: String, +} + +#[derive(Debug, clap::Args)] +pub struct Patch { + /// Base branch the patch is against + /// + /// If --topic is given, the branch must exist in the patch bundle + /// --reply-to refers to, or the default entry to reply to on that + /// topic. Otherwise, the branch must exist in the drop + /// metadata. Shorthand branch names are accepted. + /// + /// If not given, "main" or "master" is tried, in that order. + #[clap(long = "base", value_parser, value_name = "REF")] + base: Option<String>, + /// Head revision of the patch, in 'git rev-parse' syntax + #[clap( + long = "head", + value_parser, + value_name = "REVSPEC", + default_value = "HEAD" + )] + head: String, + /// Post the patch to a previously recorded topic + #[clap(long, value_parser, value_name = "TOPIC")] + topic: Option<Topic>, + /// Reply to a particular entry within a topic + /// + /// Only considered if --topic is given. + #[clap(long, value_parser, value_name = "ID")] + reply_to: Option<git2::Oid>, +} + +#[derive(Debug, clap::Args)] +pub struct Comment { + /// The topic to comment on + #[clap(value_parser, value_name = "TOPIC")] + topic: Topic, + /// Reply to a particular entry within the topic + #[clap(long, value_parser, value_name = "ID")] + reply_to: Option<git2::Oid>, +} + +pub enum Kind { + Merges { + common: Common, + remote: Option<Remote>, + force: bool, + }, + Snapshot { + common: Common, + }, + Comment { + common: Common, + remote: Option<Remote>, + comment: Comment, + }, + Patch { + common: Common, + remote: Option<Remote>, + patch: Patch, + }, +} + +impl Kind { + fn common(&self) -> &Common { + match self { + Self::Merges { common, .. } + | Self::Snapshot { common } + | Self::Comment { common, .. } + | Self::Patch { common, .. } => common, + } + } + + fn remote(&self) -> Option<&Remote> { + match self { + Self::Merges { remote, .. } + | Self::Comment { remote, .. } + | Self::Patch { remote, .. } => remote.as_ref(), + Self::Snapshot { .. } => None, + } + } + + fn accept_options(&self, drop: &DropHead) -> patches::AcceptOptions { + let mut options = patches::AcceptOptions::default(); + match self { + Self::Merges { common, .. } => { + options.allow_fat_pack = true; + options.max_branches = drop.meta.roles.branches.len(); + options.max_refs = options.max_branches + common.ids.len() + 1; + options.max_commits = 100_000; + }, + Self::Snapshot { .. } => { + options.allow_fat_pack = true; + options.allowed_refs = SNAPSHOT_REFS.clone(); + options.max_branches = usize::MAX; + options.max_refs = usize::MAX; + options.max_commits = usize::MAX; + options.max_notes = usize::MAX; + options.max_tags = usize::MAX; + }, + + _ => {}, + } + + options + } +} + +struct Resolved { + repo: prepare::Repo, + signer_id: IdentityId, + bundle_dir: PathBuf, +} + +impl Common { + fn resolve(&self) -> cmd::Result<Resolved> { + let drp = git::repo::open(&self.git_dir)?; + let ids = self.id_path.open_git(); + let src = match self.src_dir.as_ref() { + None => { + let cwd = env::current_dir()?; + (cwd != self.git_dir).then_some(cwd) + }, + Some(dir) => Some(dir.to_owned()), + } + .as_deref() + .map(git::repo::open_bare) + .transpose()?; + + debug!( + "drop: {}, src: {:?}, ids: {:?}", + drp.path().display(), + src.as_ref().map(|r| r.path().display()), + env::join_paths(ids.iter().map(|r| r.path())) + ); + + // IT_ID_PATH could differ from what was used at initialisation + git::add_alternates(&drp, &ids)?; + + let repo = prepare::Repo::new(drp, ids, src); + let signer_id = match self.id { + Some(id) => id, + None => cfg::git::identity(&repo.source().config()?)? + .ok_or_else(|| anyhow!("no identity configured for signer"))?, + }; + let bundle_dir = if self.bundle_dir.is_absolute() { + self.bundle_dir.clone() + } else { + repo.target().path().join(&self.bundle_dir) + }; + + Ok(Resolved { + repo, + signer_id, + bundle_dir, + }) + } +} + +static SNAPSHOT_REFS: Lazy<GlobSet> = Lazy::new(|| { + GlobSetBuilder::new() + .add(GLOB_IT_TOPICS.clone()) + .add(GLOB_IT_BUNDLES.clone()) + .add(GLOB_IT_IDS.clone()) + .build() + .unwrap() +}); + +pub fn create(args: Kind) -> cmd::Result<patches::Record> { + let Resolved { + repo, + signer_id, + bundle_dir, + } = args.common().resolve()?; + let drop_ref: Cow<str> = match args.remote() { + Some(remote) => { + let full = repo + .source() + .resolve_reference_from_short_name(&remote.drop_ref)?; + full.name() + .ok_or_else(|| anyhow!("invalid drop ref"))? + .to_owned() + .into() + }, + None if repo.target().is_bare() => REF_HEADS_PATCHES.into(), + None => REF_IT_PATCHES.into(), + }; + + let mut signer = cfg::git::signer(&repo.source().config()?, ui::askpass)?; + let drop = patches::DropHead::from_refname(repo.target(), &drop_ref)?; + + let spec = match &args { + Kind::Merges { force, .. } => prepare::Kind::Mergepoint { force: *force }, + Kind::Snapshot { .. } => prepare::Kind::Snapshot { incremental: true }, + Kind::Comment { comment, .. } => prepare::Kind::Comment { + topic: comment.topic.clone(), + reply: comment.reply_to, + }, + Kind::Patch { patch, .. } => { + let (name, base_ref) = dwim_base( + repo.target(), + &drop, + patch.topic.as_ref(), + patch.reply_to, + patch.base.as_deref(), + )? + .ok_or_else(|| anyhow!("unable to determine base branch"))?; + let base = repo + .target() + .find_reference(&base_ref)? + .peel_to_commit()? + .id(); + let head = repo + .source() + .revparse_single(&patch.head)? + .peel_to_commit()? + .id(); + + prepare::Kind::Patch { + head, + base, + name, + re: patch.topic.as_ref().map(|t| (t.clone(), patch.reply_to)), + } + }, + }; + + let mut patch = prepare::Preparator::new( + &repo, + &drop, + prepare::Submitter { + signer: &mut signer, + id: signer_id, + }, + ) + .prepare_patch( + &bundle_dir, + spec, + args.common().message.clone(), + &args.common().ids, + )?; + + if args.common().dry_run { + info!("--dry-run given, stopping here"); + cmd::abort!(); + } + + match args.remote() { + Some(remote) => patch.submit(remote.url.clone()), + None => patch.try_accept(patches::AcceptArgs { + unbundle_prefix: REF_IT_BUNDLES, + drop_ref: &drop_ref, + seen_ref: REF_IT_SEEN, + repo: repo.target(), + signer: &mut signer, + ipfs_api: args.common().ipfs_api.as_ref(), + options: args.accept_options(&drop), + }), + } +} + +fn dwim_base( + repo: &git2::Repository, + drop: &DropHead, + topic: Option<&Topic>, + reply_to: Option<git2::Oid>, + base: Option<&str>, +) -> cmd::Result<Option<(Refname, Refname)>> { + let mut candidates = BTreeMap::new(); + match topic { + Some(topic) => { + let reply_to = reply_to.map(Ok).unwrap_or_else(|| { + iter::topic::default_reply_to(repo, topic)? + .ok_or_else(|| anyhow!("topic {topic} not found")) + })?; + let mut patch_id = None; + for note in iter::topic(repo, topic) { + let note = note?; + if note.header.id == reply_to { + patch_id = Some(note.header.patch.id); + break; + } + } + let patch_id = patch_id.ok_or_else(|| { + anyhow!("no patch found corresponding to topic: {topic}, reply-to: {reply_to}") + })?; + + let prefix = format!("{REF_IT_BUNDLES}/{patch_id}/"); + let mut iter = repo.references_glob(&format!("{prefix}**"))?; + for candidate in iter.names() { + let candidate = candidate?; + if let Some(suf) = candidate.strip_prefix(&prefix) { + if !suf.starts_with("it/") { + candidates.insert(format!("refs/{suf}"), candidate.parse()?); + } + } + } + }, + + None => candidates.extend( + drop.meta + .roles + .branches + .keys() + .cloned() + .map(|name| (name.to_string(), name)), + ), + }; + + const FMTS: &[fn(&str) -> String] = &[ + |s| s.to_owned(), + |s| format!("refs/{}", s), + |s| format!("refs/heads/{}", s), + |s| format!("refs/tags/{}", s), + ]; + + debug!("dwim candidates: {candidates:#?}"); + + match base { + Some(base) => { + for (virt, act) in candidates { + for f in FMTS { + let name = f(base); + if name == virt { + let refname = name.parse()?; + return Ok(Some((refname, act))); + } + } + } + Ok(None) + }, + + // nb. biased towards "main" because we use a BTreeMap + None => Ok(candidates.into_iter().find_map(|(k, _)| match k.as_str() { + "refs/heads/main" => Some((Refname::main(), TrackingBranch::main().into_refname())), + "refs/heads/master" => { + Some((Refname::master(), TrackingBranch::master().into_refname())) + }, + _ => None, + })), + } +} diff --git a/src/cmd/patch/prepare.rs b/src/cmd/patch/prepare.rs new file mode 100644 index 0000000..06d5ec9 --- /dev/null +++ b/src/cmd/patch/prepare.rs @@ -0,0 +1,615 @@ +// Copyright © 2022 Kim Altintop <kim@eagain.io> +// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception + +use std::path::{ + Path, + PathBuf, +}; + +use anyhow::{ + anyhow, + bail, + ensure, +}; +use either::Either::Left; +use sha2::{ + Digest, + Sha256, +}; + +use crate::{ + bundle, + cmd::{ + self, + ui::{ + debug, + edit_comment, + edit_cover_letter, + info, + warn, + }, + }, + git::{ + self, + if_not_found_none, + Refname, + }, + keys::Signer, + metadata::{ + self, + git::{ + FromGit, + GitMeta, + META_FILE_ID, + }, + identity::{ + self, + IdentityId, + }, + ContentHash, + KeyId, + }, + patches::{ + self, + iter::{ + dropped, + topic, + }, + notes, + record, + Topic, + REF_IT_BUNDLES, + REF_IT_PATCHES, + TOPIC_MERGES, + TOPIC_SNAPSHOTS, + }, +}; + +pub enum Kind { + Mergepoint { + force: bool, + }, + Snapshot { + incremental: bool, + }, + Patch { + head: git2::Oid, + base: git2::Oid, + name: Refname, + re: Option<(Topic, Option<git2::Oid>)>, + }, + Comment { + topic: Topic, + reply: Option<git2::Oid>, + }, +} + +pub struct Submitter<'a, S: ?Sized> { + pub signer: &'a mut S, + pub id: IdentityId, +} + +pub struct Repo { + drp: git2::Repository, + src: Option<git2::Repository>, + ids: Vec<git2::Repository>, +} + +impl Repo { + pub fn new( + drp: git2::Repository, + ids: Vec<git2::Repository>, + src: Option<git2::Repository>, + ) -> Self { + Self { drp, ids, src } + } + + /// Repository containing the patch objects + pub fn source(&self) -> &git2::Repository { + self.src.as_ref().unwrap_or(&self.drp) + } + + /// Repository containing the drop state + pub fn target(&self) -> &git2::Repository { + &self.drp + } + + /// Repositories containing identity histories + pub fn id_path(&self) -> &[git2::Repository] { + &self.ids + } +} + +pub struct Preparator<'a, S: ?Sized> { + repo: &'a Repo, + drop: &'a patches::DropHead<'a>, + submitter: Submitter<'a, S>, +} + +impl<'a, S: Signer> Preparator<'a, S> { + pub fn new( + repo: &'a Repo, + drop: &'a patches::DropHead<'a>, + submitter: Submitter<'a, S>, + ) -> Self { + Self { + repo, + drop, + submitter, + } + } + + pub fn prepare_patch( + &mut self, + bundle_dir: &Path, + kind: Kind, + message: Option<String>, + additional_ids: &[IdentityId], + ) -> cmd::Result<patches::Submission> { + let mut header = bundle::Header::default(); + + match kind { + Kind::Mergepoint { force } => { + mergepoint(self.repo, &self.drop.meta, &mut header, force)?; + ensure!( + !header.references.is_empty(), + "refusing to create empty checkpoint" + ); + self.annotate_checkpoint(&mut header, &TOPIC_MERGES, message)?; + }, + Kind::Snapshot { incremental } => { + snapshot(self.repo, &mut header, incremental)?; + ensure!( + !header.references.is_empty(), + "refusing to create empty snapshot" + ); + self.annotate_checkpoint(&mut header, &TOPIC_SNAPSHOTS, message)?; + }, + Kind::Patch { + head, + base, + name, + re, + } => { + ensure!(base != head, "refusing to create empty patch"); + ensure!( + if_not_found_none(self.repo.source().merge_base(base, head))?.is_some(), + "{base} is not reachable from {head}" + ); + info!("Adding patch for {name}: {base}..{head}"); + header.add_prerequisite(&base); + header.add_reference(name, &head); + self.annotate_patch(&mut header, message, re)?; + }, + Kind::Comment { topic, reply } => { + self.annotate_comment(&mut header, topic, message, reply)?; + }, + } + + for id in additional_ids { + Identity::find( + self.repo.target(), + &self.drop.ids, + self.repo.id_path(), + cmd::id::identity_ref(Left(id))?, + )? + .update(&mut header); + } + + let signer_hash = { + let keyid = self.submitter.signer.ident().keyid(); + let id_ref = cmd::id::identity_ref(Left(&self.submitter.id))?; + let id = Identity::find( + self.repo.target(), + &self.drop.ids, + self.repo.id_path(), + id_ref, + )?; + ensure!( + id.contains(&keyid), + "signing key {keyid} not in identity {}", + id.id() + ); + id.update(&mut header); + + id.hash().clone() + }; + + let bundle = patches::Bundle::create(bundle_dir, self.repo.source(), header)?; + let signature = bundle + .sign(self.submitter.signer) + .map(|signature| patches::Signature { + signer: signer_hash, + signature: signature.into(), + })?; + + Ok(patches::Submission { signature, bundle }) + } + + fn annotate_checkpoint( + &mut self, + bundle: &mut bundle::Header, + topic: &Topic, + message: Option<String>, + ) -> cmd::Result<()> { + let kind = if topic == &*TOPIC_MERGES { + notes::CheckpointKind::Merge + } else if topic == &*TOPIC_SNAPSHOTS { + notes::CheckpointKind::Snapshot + } else { + bail!("not a checkpoint topic: {topic}") + }; + let note = notes::Simple::checkpoint(kind, bundle.references.clone(), message); + let parent = topic::default_reply_to(self.repo.target(), topic)? + .map(|id| self.repo.source().find_commit(id)) + .transpose()?; + + self.annotate(bundle, topic, parent, ¬e) + } + + fn annotate_patch( + &mut self, + bundle: &mut bundle::Header, + cover: Option<String>, + re: Option<(Topic, Option<git2::Oid>)>, + ) -> cmd::Result<()> { + let cover = cover + .map(notes::Simple::new) + .map(Ok) + .unwrap_or_else(|| edit_cover_letter(self.repo.source()))?; + let (topic, parent) = match re { + Some((topic, reply_to)) => { + let parent = find_reply_to(self.repo, &topic, reply_to)?; + (topic, Some(parent)) + }, + None => { + // This is pretty arbitrary -- just use a random string instead? + let topic = { + let mut hasher = Sha256::new(); + hasher.update(record::Heads::from(bundle as &bundle::Header)); + serde_json::to_writer(&mut hasher, &cover)?; + hasher.update(self.submitter.signer.ident().keyid()); + Topic::from(hasher.finalize()) + }; + let parent = topic::default_reply_to(self.repo.target(), &topic)? + .map(|id| self.repo.source().find_commit(id)) + .transpose()?; + + (topic, parent) + }, + }; + + self.annotate(bundle, &topic, parent, &cover) + } + + fn annotate_comment( + &mut self, + bundle: &mut bundle::Header, + topic: Topic, + message: Option<String>, + reply_to: Option<git2::Oid>, + ) -> cmd::Result<()> { + let parent = find_reply_to(self.repo, &topic, reply_to)?; + let edit = || -> cmd::Result<notes::Simple> { + let re = notes::Simple::from_commit(self.repo.target(), &parent)?; + edit_comment(self.repo.source(), Some(&re)) + }; + let comment = message + .map(notes::Simple::new) + .map(Ok) + .unwrap_or_else(edit)?; + + self.annotate(bundle, &topic, Some(parent), &comment) + } + + fn annotate( + &mut self, + bundle: &mut bundle::Header, + topic: &Topic, + parent: Option<git2::Commit>, + note: ¬es::Simple, + ) -> cmd::Result<()> { + let repo = self.repo.source(); + let topic_ref = topic.as_refname(); + let tree = { + let mut tb = repo.treebuilder(None)?; + patches::to_tree(repo, &mut tb, note)?; + repo.find_tree(tb.write()?)? + }; + let msg = match note.subject() { + Some(s) => format!("{}\n\n{}", s, topic.as_trailer()), + None => topic.as_trailer(), + }; + let commit = git::commit_signed( + self.submitter.signer, + repo, + &msg, + &tree, + parent.as_ref().into_iter().collect::<Vec<_>>().as_slice(), + )?; + + if let Some(commit) = parent { + bundle.add_prerequisite(&commit.id()); + } + bundle.add_reference(topic_ref, &commit); + + Ok(()) + } +} + +fn mergepoint( + repos: &Repo, + meta: &metadata::drop::Verified, + bundle: &mut bundle::Header, + force: bool, +) -> git::Result<()> { + for branch in meta.roles.branches.keys() { + let sandboxed = match patches::TrackingBranch::try_from(branch) { + Ok(tracking) => tracking, + Err(e) => { + warn!("Skipping invalid branch {branch}: {e}"); + continue; + }, + }; + let head = { + let local = repos.source().find_reference(branch)?; + let head = local.peel_to_commit()?.id(); + if !force { + if let Some(upstream) = if_not_found_none(git2::Branch::wrap(local).upstream())? { + let upstream_head = upstream.get().peel_to_commit()?.id(); + if head != upstream_head { + warn!( + "Upstream {} is not even with {branch}; you may want to push first", + String::from_utf8_lossy(upstream.name_bytes()?) + ); + info!("Skipping {branch}"); + continue; + } + } + } + + head + }; + match if_not_found_none(repos.target().find_reference(&sandboxed))? { + Some(base) => { + let base = base.peel_to_commit()?.id(); + if base == head { + info!("Skipping empty checkpoint"); + } else if if_not_found_none(repos.source().merge_base(base, head))?.is_some() { + info!("Adding thin checkpoint for branch {branch}: {base}..{head}"); + bundle.add_prerequisite(&base); + bundle.add_reference(branch.clone(), &head); + } else { + warn!( + "{branch} diverges from drop state: no merge base between {base}..{head}" + ); + } + }, + + None => { + info!("Adding full checkpoint for branch {branch}: {head}"); + bundle.add_reference(branch.clone(), &head); + }, + } + } + + Ok(()) +} + +fn snapshot(repo: &Repo, bundle: &mut bundle::Header, incremental: bool) -> cmd::Result<()> { + for record in dropped::records(repo.target(), REF_IT_PATCHES) { + let record = record?; + let bundle_hash = record.bundle_hash(); + if record.is_encrypted() { + warn!("Skipping encrypted patch bundle {bundle_hash}",); + continue; + } + + if record.topic == *TOPIC_SNAPSHOTS { + if !incremental { + debug!("Full snapshot: skipping previous snapshot {bundle_hash}"); + continue; + } else { + info!("Incremental snapshot: found previous snapshot {bundle_hash}"); + for oid in record.meta.bundle.references.values().copied() { + info!("Adding prerequisite {oid} from {bundle_hash}"); + bundle.add_prerequisite(oid); + } + break; + } + } + + info!("Including {bundle_hash} in snapshot"); + for (name, oid) in &record.meta.bundle.references { + info!("Adding {oid} {name}"); + let name = patches::unbundled_ref(REF_IT_BUNDLES, &record, name)?; + bundle.add_reference(name, *oid); + } + } + + Ok(()) +} + +fn find_reply_to<'a>( + repo: &'a Repo, + topic: &Topic, + reply_to: Option<git2::Oid>, +) -> cmd::Result<git2::Commit<'a>> { + let tip = if_not_found_none(repo.target().refname_to_id(&topic.as_refname()))? + .ok_or_else(|| anyhow!("topic {topic} does not exist"))?; + let id = match reply_to { + Some(id) => { + ensure!( + repo.target().graph_descendant_of(tip, id)?, + "{id} not found in topic {topic}, cannot reply" + ); + id + }, + None => topic::default_reply_to(repo.target(), topic)?.expect("impossible: empty topic"), + }; + + Ok(repo.source().find_commit(id)?) +} + +struct Identity { + hash: ContentHash, + verified: identity::Verified, + update: Option<Range>, +} + +impl Identity { + pub fn find( + repo: &git2::Repository, + ids: &git2::Tree, + id_path: &[git2::Repository], + refname: Refname, + ) -> cmd::Result<Self> { + let find_parent = metadata::git::find_parent(repo); + + struct Meta { + hash: ContentHash, + id: identity::Verified, + } + + impl Meta { + fn identity(&self) -> &metadata::Identity { + self.id.identity() + } + } + + let (ours_in, ours) = + metadata::Identity::from_search_path(id_path, &refname).and_then(|data| { + let signer = data.meta.signed.verified(&find_parent)?; + Ok(( + data.repo, + Meta { + hash: data.meta.hash, + id: signer, + }, + )) + })?; + + let tree_path = PathBuf::from(ours.id.id().to_string()).join(META_FILE_ID); + let newer = match if_not_found_none(ids.get_path(&tree_path))? { + None => { + let start = ours_in.refname_to_id(&refname)?; + let range = Range { + refname, + start, + end: None, + }; + Self { + hash: ours.hash, + verified: ours.id, + update: Some(range), + } + }, + Some(in_tree) if ours.hash == in_tree.id() => Self { + hash: ours.hash, + verified: ours.id, + update: None, + }, + Some(in_tree) => { + let theirs = metadata::Identity::from_blob(&repo.find_blob(in_tree.id())?) + .and_then(|GitMeta { hash, signed }| { + let signer = signed.verified(&find_parent)?; + Ok(Meta { hash, id: signer }) + })?; + + if ours.identity().has_ancestor(&theirs.hash, &find_parent)? { + let range = Range::compute(ours_in, refname, theirs.hash.as_oid())?; + Self { + hash: ours.hash, + verified: ours.id, + update: range, + } + } else if theirs.identity().has_ancestor(&ours.hash, &find_parent)? { + Self { + hash: theirs.hash, + verified: theirs.id, + update: None, + } + } else { + bail!( + "provided identity at {} diverges from in-tree at {}", + ours.hash, + theirs.hash, + ) + } + }, + }; + + Ok(newer) + } + + pub fn id(&self) -> &IdentityId { + self.verified.id() + } + + pub fn hash(&self) -> &ContentHash { + &self.hash + } + + pub fn contains(&self, key: &KeyId) -> bool { + self.verified.identity().keys.contains_key(key) + } + + pub fn update(&self, bundle: &mut bundle::Header) { + if let Some(range) = &self.update { + range.add_to_bundle(bundle); + } + } +} + +struct Range { + refname: Refname, + start: git2::Oid, + end: Option<git2::Oid>, +} + +impl Range { + fn compute( + repo: &git2::Repository, + refname: Refname, + known: git2::Oid, + ) -> cmd::Result<Option<Self>> { + let start = repo.refname_to_id(&refname)?; + + let mut walk = repo.revwalk()?; + walk.push(start)?; + for oid in walk { + let oid = oid?; + let blob_id = repo + .find_commit(oid)? + .tree()? + .get_name(META_FILE_ID) + .ok_or_else(|| anyhow!("corrupt identity: missing {META_FILE_ID}"))? + .id(); + + if blob_id == known { + return Ok(if oid == start { + None + } else { + Some(Self { + refname, + start, + end: Some(oid), + }) + }); + } + } + + Ok(Some(Self { + refname, + start, + end: None, + })) + } + + fn add_to_bundle(&self, header: &mut bundle::Header) { + header.add_reference(self.refname.clone(), &self.start); + if let Some(end) = self.end { + header.add_prerequisite(&end); + } + } +} |