summaryrefslogtreecommitdiff
path: root/src/cmd/patch
diff options
context:
space:
mode:
authorKim Altintop <kim@eagain.io>2023-01-09 13:18:33 +0100
committerKim Altintop <kim@eagain.io>2023-01-09 13:18:33 +0100
commitd2f423521ec76406944ad83098ec33afe20c692b (patch)
treeafd86bcb088eebdd61ba4e52fa666ff0f41c42a2 /src/cmd/patch
This is it
Squashed commit of all the exploration history. Development starts here. Signed-off-by: Kim Altintop <kim@eagain.io>
Diffstat (limited to 'src/cmd/patch')
-rw-r--r--src/cmd/patch/create.rs483
-rw-r--r--src/cmd/patch/prepare.rs615
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, &note)
+ }
+
+ 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: &notes::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);
+ }
+ }
+}