Repository: git-bug/git-bug

Issue:
problem with gitlab push
I have a self hosted GitLab 15.6.0-ee and I cannot push any locally created issues.
I am using the pre built 0.8.0 binary for win64

- Bridge configured correctly (I think)
- Pulled issues from project (with some errors)
- Adopted my gitlab user which comes when I pull
- Add a new bug/issue using the WebUI
- git bug ls shows the new bug

```
>git bug bridge push
exported 0 issues with default bridge
```

As mentioned, the git bug bridge pull did not run cleanly:
```
>git bug bridge pull
[8844b1e] new identity: 8844b1eb4852a9a84f497c78a93057d2f588a4c049652d062d75885b35b1f4e5
[abbefcc] new issue: abbefcc80a4fee9566b8205f324c4b2e8ee765a81118d77296eddb30605493a5
import error on entity 608: issue event creation: unexpected event
import error on entity 609: issue event creation: unexpected event
import error on entity 610: issue event creation: unexpected event
[abbefcc] new comment: a3b6b3efcbc80a54fee29566eb82085f3274c4bc2e8e4e7651a811018d7d7296
[abbefcc] changed status with op: d08663dff96760da23ec10b611f48aa5662ae54f3c1c1653602bb36bf02e5fea
[04e5722] new issue: 04e5722e69e3a4d9f32fea893bc11b7147d66efe1f76723838d82b7ed9d0b48f
[04e5722] changed status with op: 0b67543735ca90a9662d3a4c2af39a10d945c02dfc70ae602ca7ff9195ff9492
[2b81d80] new issue: 2b81d800b10d954848ecdfb05158cb58d5521562001725e29cc773a1b7e155b5
[2b81d80] new comment: 2cb0851d8f00b110d95d48489ecdf9b051158cb158d5e521576200f17253e29c
import error on entity 713: issue event creation: unexpected event
import error on entity 714: issue event creation: unexpected event
[2b81d80] changed status with op: b1c1eca65b76a98e22404db6ef791568b838539629070ca070391907f23764a5
[13abb35] new issue: 13abb3506964e26e26d1a6e80416eacdf0ec88a2a398f92425819759bb1d7a94
[13abb35] new comment: 1b3fa3bb3a5069f64e246e261d1a6de804816eabcdf0eec883a2a3098f982425
[39f55ae] new issue: 39f55aeffc6b18e7a65fb9c4401b62fbd592fb1bb5241a8b0c870b41b24d5992
import error on entity 913: issue event creation: unexpected event
import error on entity 915: issue event creation: unexpected event
[663f0f4] new issue: 663f0f4f501039cf5d29ad1ab118af1017910a1784bb8b8e819aad95a386c6be
import error on entity 914: issue event creation: unexpected event
[db32949] new issue: db32949776b3a09d9269749d2589f64d6ab333550149bf94339321c492d18372
import error on entity 909: issue event creation: unexpected event
[db32949] new comment: dcb938294e97767b3a089d928697489d25889f644d6acb33395501849bfa9433
[cafc218] new issue: cafc218f770def0533393798257caf9d3d07c5a60bf6984a08d414e0110427a8
[cafc218] new comment: c0a6f3c2198f77d0def1053363937a982577caf59d3d907c51a60b3f69834a08
import error on entity 912: issue event creation: unexpected event
import error on entity 999: issue event creation: unexpected event
[4524143] new issue: 45241432218c61b8587453bcd68fff6445182ec752b64120db39936b318241e0
[4524143] new comment: 44582c41463221a8c61cb858e74536bcd618fffe64453182ebc7524b641b20db
[af16b22] new issue: af16b22e1400d0b00530603cd82ac8cbab8242ad8e7d5e63a14e2286d7491f04
import error on entity 997: issue event creation: unexpected event
[43dd5be] new issue: 43dd5beb54799c1ef4c5e1b73e3f6373261931ee3171bd106e6695d8fe03fc97
import error on entity 1013: issue event creation: unexpected event
import error on entity 1014: issue event creation: unexpected event
imported 11 issues and 1 identities with default bridge
```



Hints from issue comments:
@MichaelMure: @5nord FYI

Looks like that:
- Gitlab released a new version with new kind of event
- the gitlab bridge assign `EventUnknown` to those when they are not recognized (dropping the details of the event) (for example https://github.com/MichaelMure/git-bug/blob/e762290e237f1e62916e17a901d1f819960d3378/bridge/gitlab/event.go#L144)
- the gitlab bridge return an error when such an `EventUnknown` occurs: https://github.com/MichaelMure/git-bug/blob/9b98fc06489353053564b431ac0c0d73a5c55a56/bridge/gitlab/import.go#L332

That last part is problematic as it's quite reasonable for gitlab to add more events. Point number 2 could also be a problem, depending on how we look at it. I suppose we should just ignore the unknown events. Currently it fails the import of that particular issue (it doesn't commit).
@buestad any idea what kind of event that could be, if you look at the issues that fail to import?

As for the push problem it could be related to that first problem but I suspect it's a different one. The CI does work though, so that could be specific to that new gitlab version.




@buestad: I am 99% sure that the unexpected event is related to commit links. (When a commit message has a # and issue number)
![image](https://user-images.githubusercontent.com/18750265/210523448-275f0a81-bfe3-4d90-93a8-88043a193f62.png)

It looks like after switching identity (user adopt) after the first pull there are some mismatch with the UUIDs on the issues? This also happens if I do a commit to the repos and the pull the issues afterwards.

Here is some console output which might be of interest?:
```
C:\Projects\git-test [main ≡]> git bug user ls
50494a5 Henrik Buestad
C:\Projects\git-test [main ≡]> git bug bridge pull
[b3883c2] new identity: b3883c275de13eb6af82192285a88abba3c9aa1fed1b24eb7b42eb449be9fcee
[b2be3a8] new issue: b2be3a8d3f974d23018214664b95393e5e81ad0621329f9a8f4b413895d8f71a
import error on entity 1022: issue event creation: unexpected event
[2644b2f] new issue: 2644b2fd06f93575899e4ad3aabad6dea8bbcc8fc7ed504f6b6da635644c59fe
import error on entity 1028: issue event creation: unexpected event
import error on entity 1029: issue event creation: unexpected event
[393b53d] new issue: 393b53d49c1d6ae3289093491a7e1c34568613e876e0cff3b59ca5b21d1d0866
imported 3 issues and 1 identities with default bridge
C:\Projects\git-test [main ≡]> git bug bridge pull
import error on entity 1022: issue event creation: unexpected event
import error on entity 1028: issue event creation: unexpected event
import error on entity 1029: issue event creation: unexpected event
imported 0 issues and 0 identities with default bridge
C:\Projects\git-test [main ≡]> git bug user ls
50494a5 Henrik Buestad
b3883c2 Buestad Henrik (buesthe)
C:\Projects\git-test [main ≡]> git bug user adopt b3883c2
Your identity is now: Buestad Henrik (buesthe)
C:\Projects\git-test [main ≡]> git bug bridge pull
import error on entity 653: issue event creation: Multiple matching operation found:
b5836414f64f108ae79ddc66ac89cbabad671f322c5b8b247923acbc14e0c19c
eb41f3e8ce9ef48f0875f92a5dcaa53befdf8b0dfe7231d0859c2934a0b023ea
import error on entity 654: issue event creation: Multiple matching operation found:
c4fe840e1794f9e93d70f0a7c425158f4ab07b19d062da1535d02a4724222849
18e1778bea1b7fd702e8b6420b1819e4bf28d1b26fda6cf5cdc6c3114dd259c7
import error on entity 655: issue event creation: Multiple matching operation found:
d3f915e32b204acb639dccfba115b068cf82aa556f8866db2e71a7fad7a4661c
49e4e737343da2765e0c5cbf9d57f9c9a06dcb2113c0f6e72cea6e3c719fed71
import error on entity 656: issue event creation: Multiple matching operation found:
a21da9149c183079c80b6959a9a506d23921bddb0e6f73827c00e8c719bcbb31
70a00d43b87409eb9c724a9676a572d28bd1ce34e332f1c6943f8bacd757cc26
import error on entity 657: issue event creation: Multiple matching operation found:
3472314a03bf9a2277126ce6327099541c3f8cc3acdec54e067c5d44c293ad85
1a862a286cf4da2f6a314c111091ed487e4d57f42598d2585c64102e3e7601e7
import error on entity 658: issue event creation: Multiple matching operation found:
332fb56736057a9fff417cee8005bc56040ee5cea4932bde8bb54e15cec16826
719f937b3022ce8234abd98f20b5a1074f68cb69655e74783d2d8258da88ac75
import error on entity 1022: issue event creation: unexpected event
import error on entity 659: issue event creation: Multiple matching operation found:
71ef2eacca0e9de9542b147d31b8214458eb2b92c44e1c6935e9b80e6b417e70
e1067fb83041ffa5eda440c7fd2455945e901c3cd7be3e14bb1a3cbaefb5676f
import error on entity 1028: issue event creation: unexpected event
import error on entity 1029: issue event creation: unexpected event
imported 0 issues and 0 identities with default bridge
C:\Projects\git-test [main ≡]> git bug ls
b2be3a8 open    Add something sensible to first text file      ◼ ◼      Buestad Henrik…
2644b2f open    test from GitLab                                 ◼      Buestad Henrik…
393b53d open    simple issue                                            Buestad Henrik…
C:\Projects\git-test [main ≡]> git bug webui
Web UI: http://127.0.0.1:60352
Graphql API: http://127.0.0.1:60352/graphql
Graphql Playground: http://127.0.0.1:60352/playground
Press Ctrl+c to quit
WebUI is shutting down...
WebUI stopped
C:\Projects\git-test [main ≡]> git bug ls
b2be3a8 open    Add something sensible to first text file      ◼ ◼      Buestad Henrik…
2644b2f open    test from GitLab                                 ◼      Buestad Henrik…
393b53d open    simple issue                                            Buestad Henrik…
98b6959 open    Test from git-bug                                       Buestad Henrik…
C:\Projects\git-test [main ≡]> git bug bridge push
exported 0 issues with default bridge
```@github-actions: This bot triages untriaged issues and PRs according to the following rules:

- After 90 days of inactivity, the `lifecycle/stale` label is applied
- After 30 days of inactivity since `lifecycle/stale` was applied, the issue is closed

To remove the stale status, you can:

- Remove the `lifecycle/stale` label
- Comment on this issue@github-actions: This bot triages issues in order to help the maintainers identify what
needs attention, according to the following lifecycle rules:

- After 90 days of inactivity, `lifecycle/stale` is applied
- After 90 days of inactivity since `lifecycle/stale` was applied,
  `lifecycle/rotten` is applied

**This bot will not automatically close stale issues.**

To remove the stale status, you can:

- Remove the stale label from this issue
- Comment on this issue
- Close this issue
- Offer to help out with triaging

---

To avoid automatic lifecycle management of this issue, add
`lifecycle/frozen`.@github-actions: This bot triages issues in order to help the maintainers identify what
needs attention, according to the following lifecycle rules:

- After 90 days of inactivity, `lifecycle/stale` is applied
- After 90 days of inactivity since `lifecycle/stale` was applied,
  `lifecycle/rotten` is applied

**This bot will not automatically close stale issues.**

To remove the stale status, you can:

- Remove the stale label from this issue
- Comment on this issue
- Close this issue
- Offer to help out with triaging

---

To avoid automatic lifecycle management of this issue, add
`lifecycle/frozen`.@sudoforge: As of v0.8.1, this appears to be resolved. The CI pipeline runs an import/export test with gitlab.

Solution patch:
diff --git a/bridge/gitlab/event.go b/bridge/gitlab/event.go
index 483af921..0653269d 100644
--- a/bridge/gitlab/event.go
+++ b/bridge/gitlab/event.go
@@ -40,6 +40,7 @@ const (
 	EventRemoveLabel
 	EventMentionedInIssue
 	EventMentionedInMergeRequest
+	EventMentionedInCommit
 )
 
 var _ Event = &NoteEvent{}
@@ -97,6 +98,9 @@ func (n NoteEvent) Kind() EventKind {
 	case strings.HasPrefix(n.Body, "mentioned in merge request"):
 		return EventMentionedInMergeRequest
 
+	case strings.HasPrefix(n.Body, "mentioned in commit"):
+		return EventMentionedInCommit
+
 	default:
 		return EventUnknown
 	}
diff --git a/bridge/gitlab/import.go b/bridge/gitlab/import.go
index 5947fb60..e4330b4c 100644
--- a/bridge/gitlab/import.go
+++ b/bridge/gitlab/import.go
@@ -324,7 +324,8 @@ func (gi *gitlabImporter) ensureIssueEvent(repo *cache.RepoCache, b *cache.BugCa
 		EventLocked,
 		EventUnlocked,
 		EventMentionedInIssue,
-		EventMentionedInMergeRequest:
+		EventMentionedInMergeRequest,
+		EventMentionedInCommit:
 
 		return nil
 
diff --git a/cache/bug_subcache.go b/cache/bug_subcache.go
index 920fe1dc..21c9a6d2 100644
--- a/cache/bug_subcache.go
+++ b/cache/bug_subcache.go
@@ -38,6 +38,7 @@ func NewRepoCacheBug(repo repository.ClockedRepo,
 		ReadWithResolver:    bug.ReadWithResolver,
 		ReadAllWithResolver: bug.ReadAllWithResolver,
 		Remove:              bug.Remove,
+		RemoveAll:           bug.RemoveAll,
 		MergeAll:            bug.MergeAll,
 	}
 
diff --git a/cache/identity_subcache.go b/cache/identity_subcache.go
index f862ca8b..05a91358 100644
--- a/cache/identity_subcache.go
+++ b/cache/identity_subcache.go
@@ -39,7 +39,8 @@ func NewRepoCacheIdentity(repo repository.ClockedRepo,
 		ReadAllWithResolver: func(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan entity.StreamedEntity[*identity.Identity] {
 			return identity.ReadAllLocal(repo)
 		},
-		Remove: identity.RemoveIdentity,
+		Remove:    identity.Remove,
+		RemoveAll: identity.RemoveAll,
 		MergeAll: func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult {
 			return identity.MergeAll(repo, remote)
 		},
diff --git a/cache/repo_cache.go b/cache/repo_cache.go
index 99e9abbd..9e45d1f1 100644
--- a/cache/repo_cache.go
+++ b/cache/repo_cache.go
@@ -30,8 +30,9 @@ var _ repository.RepoKeyring = &RepoCache{}
 type cacheMgmt interface {
 	Typename() string
 	Load() error
-	Build() error
+	Build() <-chan BuildEvent
 	SetCacheSize(size int)
+	RemoveAll() error
 	MergeAll(remote string) <-chan entity.MergeResult
 	GetNamespace() string
 	Close() error
@@ -212,6 +213,7 @@ const (
 	BuildEventCacheIsBuilt
 	BuildEventRemoveLock
 	BuildEventStarted
+	BuildEventProgress
 	BuildEventFinished
 )
 
@@ -223,6 +225,10 @@ type BuildEvent struct {
 	Typename string
 	// Event is the type of the event.
 	Event BuildEventType
+	// Total is the total number of element being built. Set if Event is BuildEventStarted.
+	Total int64
+	// Progress is the current count of processed element. Set if Event is BuildEventProgress.
+	Progress int64
 }
 
 func (c *RepoCache) buildCache(events chan BuildEvent) {
@@ -233,23 +239,13 @@ func (c *RepoCache) buildCache(events chan BuildEvent) {
 		wg.Add(1)
 		go func(subcache cacheMgmt) {
 			defer wg.Done()
-			events <- BuildEvent{
-				Typename: subcache.Typename(),
-				Event:    BuildEventStarted,
-			}
 
-			err := subcache.Build()
-			if err != nil {
-				events <- BuildEvent{
-					Typename: subcache.Typename(),
-					Err:      err,
+			buildEvents := subcache.Build()
+			for buildEvent := range buildEvents {
+				events <- buildEvent
+				if buildEvent.Err != nil {
+					return
 				}
-				return
-			}
-
-			events <- BuildEvent{
-				Typename: subcache.Typename(),
-				Event:    BuildEventFinished,
 			}
 		}(subcache)
 	}
diff --git a/cache/repo_cache_common.go b/cache/repo_cache_common.go
index f768b8e2..759536bd 100644
--- a/cache/repo_cache_common.go
+++ b/cache/repo_cache_common.go
@@ -3,12 +3,12 @@ package cache
 import (
 	"sync"
 
-	"github.com/go-git/go-billy/v5"
 	"github.com/pkg/errors"
 
 	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/util/multierr"
 )
 
 func (c *RepoCache) Name() string {
@@ -56,7 +56,7 @@ func (c *RepoCache) GetRemotes() (map[string]string, error) {
 }
 
 // LocalStorage return a billy.Filesystem giving access to $RepoPath/.git/git-bug
-func (c *RepoCache) LocalStorage() billy.Filesystem {
+func (c *RepoCache) LocalStorage() repository.LocalStorage {
 	return c.repo.LocalStorage()
 }
 
@@ -82,6 +82,15 @@ func (c *RepoCache) Fetch(remote string) (string, error) {
 	return c.repo.FetchRefs(remote, prefixes...)
 }
 
+// RemoveAll deletes all entities from the cache and the disk.
+func (c *RepoCache) RemoveAll() error {
+	var errWait multierr.ErrWaitGroup
+	for _, mgmt := range c.subcaches {
+		errWait.Go(mgmt.RemoveAll)
+	}
+	return errWait.Wait()
+}
+
 // MergeAll will merge all the available remote bug and identities
 func (c *RepoCache) MergeAll(remote string) <-chan entity.MergeResult {
 	out := make(chan entity.MergeResult)
@@ -163,6 +172,19 @@ func (c *RepoCache) SetUserIdentity(i *IdentityCache) error {
 	return nil
 }
 
+func (c *RepoCache) ClearUserIdentity() error {
+	c.muUserIdentity.Lock()
+	defer c.muUserIdentity.Unlock()
+
+	err := identity.ClearUserIdentity(c.repo)
+	if err != nil {
+		return err
+	}
+
+	c.userIdentityId = ""
+	return nil
+}
+
 func (c *RepoCache) GetUserIdentity() (*IdentityCache, error) {
 	c.muUserIdentity.RLock()
 	if c.userIdentityId != "" {
diff --git a/cache/subcache.go b/cache/subcache.go
index 7b599b10..09e53c23 100644
--- a/cache/subcache.go
+++ b/cache/subcache.go
@@ -34,6 +34,7 @@ type Actions[EntityT entity.Interface] struct {
 	ReadWithResolver    func(repo repository.ClockedRepo, resolvers entity.Resolvers, id entity.Id) (EntityT, error)
 	ReadAllWithResolver func(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan entity.StreamedEntity[EntityT]
 	Remove              func(repo repository.ClockedRepo, id entity.Id) error
+	RemoveAll           func(repo repository.ClockedRepo) error
 	MergeAll            func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult
 }
 
@@ -185,51 +186,98 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) write() error {
 	return f.Close()
 }
 
-func (sc *SubCache[EntityT, ExcerptT, CacheT]) Build() error {
-	sc.excerpts = make(map[entity.Id]ExcerptT)
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) Build() <-chan BuildEvent {
+	out := make(chan BuildEvent)
 
-	allEntities := sc.actions.ReadAllWithResolver(sc.repo, sc.resolvers())
+	go func() {
+		defer close(out)
 
-	index, err := sc.repo.GetIndex(sc.namespace)
-	if err != nil {
-		return err
-	}
+		out <- BuildEvent{
+			Typename: sc.typename,
+			Event:    BuildEventStarted,
+		}
 
-	// wipe the index just to be sure
-	err = index.Clear()
-	if err != nil {
-		return err
-	}
+		sc.excerpts = make(map[entity.Id]ExcerptT)
 
-	indexer, indexEnd := index.IndexBatch()
+		allEntities := sc.actions.ReadAllWithResolver(sc.repo, sc.resolvers())
 
-	for e := range allEntities {
-		if e.Err != nil {
-			return e.Err
+		index, err := sc.repo.GetIndex(sc.namespace)
+		if err != nil {
+			out <- BuildEvent{
+				Typename: sc.typename,
+				Err:      err,
+			}
+			return
+		}
+
+		// wipe the index just to be sure
+		err = index.Clear()
+		if err != nil {
+			out <- BuildEvent{
+				Typename: sc.typename,
+				Err:      err,
+			}
+			return
 		}
 
-		cached := sc.makeCached(e.Entity, sc.entityUpdated)
-		sc.excerpts[e.Entity.Id()] = sc.makeExcerpt(cached)
-		// might as well keep them in memory
-		sc.cached[e.Entity.Id()] = cached
+		indexer, indexEnd := index.IndexBatch()
+
+		for e := range allEntities {
+			if e.Err != nil {
+				out <- BuildEvent{
+					Typename: sc.typename,
+					Err:      e.Err,
+				}
+				return
+			}
 
-		indexData := sc.makeIndexData(cached)
-		if err := indexer(e.Entity.Id().String(), indexData); err != nil {
-			return err
+			cached := sc.makeCached(e.Entity, sc.entityUpdated)
+			sc.excerpts[e.Entity.Id()] = sc.makeExcerpt(cached)
+			// might as well keep them in memory
+			sc.cached[e.Entity.Id()] = cached
+
+			indexData := sc.makeIndexData(cached)
+			if err := indexer(e.Entity.Id().String(), indexData); err != nil {
+				out <- BuildEvent{
+					Typename: sc.typename,
+					Err:      err,
+				}
+				return
+			}
+
+			out <- BuildEvent{
+				Typename: sc.typename,
+				Event:    BuildEventProgress,
+				Progress: e.CurrentEntity,
+				Total:    e.TotalEntities,
+			}
 		}
-	}
 
-	err = indexEnd()
-	if err != nil {
-		return err
-	}
+		err = indexEnd()
+		if err != nil {
+			out <- BuildEvent{
+				Typename: sc.typename,
+				Err:      err,
+			}
+			return
+		}
 
-	err = sc.write()
-	if err != nil {
-		return err
-	}
+		err = sc.write()
+		if err != nil {
+			out <- BuildEvent{
+				Typename: sc.typename,
+				Err:      err,
+			}
+			return
+		}
 
-	return nil
+		out <- BuildEvent{
+			Typename: sc.typename,
+			Event:    BuildEventFinished,
+		}
+	}()
+
+	return out
 }
 
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) SetCacheSize(size int) {
@@ -399,7 +447,49 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Remove(prefix string) error {
 	delete(sc.excerpts, e.Id())
 	sc.lru.Remove(e.Id())
 
+	index, err := sc.repo.GetIndex(sc.namespace)
+	if err != nil {
+		sc.mu.Unlock()
+		return err
+	}
+
+	err = index.Remove(e.Id().String())
+	sc.mu.Unlock()
+	if err != nil {
+		return err
+	}
+
+	return sc.write()
+}
+
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) RemoveAll() error {
+	sc.mu.Lock()
+
+	err := sc.actions.RemoveAll(sc.repo)
+	if err != nil {
+		sc.mu.Unlock()
+		return err
+	}
+
+	for id, _ := range sc.cached {
+		delete(sc.cached, id)
+		sc.lru.Remove(id)
+	}
+	for id, _ := range sc.excerpts {
+		delete(sc.excerpts, id)
+	}
+
+	index, err := sc.repo.GetIndex(sc.namespace)
+	if err != nil {
+		sc.mu.Unlock()
+		return err
+	}
+
+	err = index.Clear()
 	sc.mu.Unlock()
+	if err != nil {
+		return err
+	}
 
 	return sc.write()
 }
diff --git a/commands/execenv/env.go b/commands/execenv/env.go
index 4be7c247..d2d1c301 100644
--- a/commands/execenv/env.go
+++ b/commands/execenv/env.go
@@ -7,6 +7,8 @@ import (
 	"os"
 
 	"github.com/spf13/cobra"
+	"github.com/vbauerster/mpb/v8"
+	"github.com/vbauerster/mpb/v8/decor"
 
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entities/identity"
@@ -50,6 +52,10 @@ type Out interface {
 	// Reset clear what has been recorded as written in the output before.
 	// This only works in test scenario.
 	Reset()
+
+	// Raw return the underlying io.Writer, or itself if not.
+	// This is useful if something need to access the raw file descriptor.
+	Raw() io.Writer
 }
 
 type out struct {
@@ -89,6 +95,10 @@ func (o out) Reset() {
 	panic("only work with a test env")
 }
 
+func (o out) Raw() io.Writer {
+	return o.Writer
+}
+
 // LoadRepo is a pre-run function that load the repository for use in a command
 func LoadRepo(env *Env) func(*cobra.Command, []string) error {
 	return func(cmd *cobra.Command, args []string) error {
@@ -143,18 +153,9 @@ func LoadBackend(env *Env) func(*cobra.Command, []string) error {
 		var events chan cache.BuildEvent
 		env.Backend, events = cache.NewRepoCache(env.Repo)
 
-		for event := range events {
-			if event.Err != nil {
-				return event.Err
-			}
-			switch event.Event {
-			case cache.BuildEventCacheIsBuilt:
-				env.Err.Println("Building cache... ")
-			case cache.BuildEventStarted:
-				env.Err.Printf("[%s] started\n", event.Typename)
-			case cache.BuildEventFinished:
-				env.Err.Printf("[%s] done\n", event.Typename)
-			}
+		err = CacheBuildProgressBar(env, events)
+		if err != nil {
+			return err
 		}
 
 		cleaner := func(env *Env) interrupt.CleanerFunc {
@@ -213,3 +214,41 @@ func CloseBackend(env *Env, runE func(cmd *cobra.Command, args []string) error)
 		return err
 	}
 }
+
+func CacheBuildProgressBar(env *Env, events chan cache.BuildEvent) error {
+	var progress *mpb.Progress
+	var bars = make(map[string]*mpb.Bar)
+
+	for event := range events {
+		if event.Err != nil {
+			return event.Err
+		}
+
+		if progress == nil {
+			progress = mpb.New(mpb.WithOutput(env.Err.Raw()))
+		}
+
+		switch event.Event {
+		case cache.BuildEventCacheIsBuilt:
+			env.Err.Println("Building cache... ")
+		case cache.BuildEventStarted:
+			bars[event.Typename] = progress.AddBar(-1,
+				mpb.BarRemoveOnComplete(),
+				mpb.PrependDecorators(
+					decor.Name(event.Typename, decor.WCSyncSpace),
+					decor.CountersNoUnit("%d / %d", decor.WCSyncSpace),
+				),
+				mpb.AppendDecorators(decor.Percentage(decor.WCSyncSpace)),
+			)
+		case cache.BuildEventProgress:
+			bars[event.Typename].SetTotal(event.Total, false)
+			bars[event.Typename].SetCurrent(event.Progress)
+		}
+	}
+
+	if progress != nil {
+		progress.Shutdown()
+	}
+
+	return nil
+}
diff --git a/commands/root.go b/commands/root.go
index cb4fd686..0c854739 100644
--- a/commands/root.go
+++ b/commands/root.go
@@ -81,6 +81,7 @@ the same git remote you are already using to collaborate with other people.
 
 	cmd.AddCommand(newCommandsCommand())
 	cmd.AddCommand(newVersionCommand())
+	cmd.AddCommand(newWipeCommand())
 
 	return cmd
 }
diff --git a/commands/webui.go b/commands/webui.go
index 0b3b24a6..31313146 100644
--- a/commands/webui.go
+++ b/commands/webui.go
@@ -108,19 +108,10 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	mrc := cache.NewMultiRepoCache()
 
 	_, events := mrc.RegisterDefaultRepository(env.Repo)
-	for event := range events {
-		if event.Err != nil {
-			env.Err.Printf("Cache building error [%s]: %v\n", event.Typename, event.Err)
-			continue
-		}
-		switch event.Event {
-		case cache.BuildEventCacheIsBuilt:
-			env.Err.Println("Building cache... ")
-		case cache.BuildEventStarted:
-			env.Err.Printf("[%s] started\n", event.Typename)
-		case cache.BuildEventFinished:
-			env.Err.Printf("[%s] done\n", event.Typename)
-		}
+
+	err := execenv.CacheBuildProgressBar(env, events)
+	if err != nil {
+		return err
 	}
 
 	var errOut io.Writer
diff --git a/commands/wipe.go b/commands/wipe.go
new file mode 100644
index 00000000..0ec53f49
--- /dev/null
+++ b/commands/wipe.go
@@ -0,0 +1,58 @@
+package commands
+
+import (
+	"github.com/spf13/cobra"
+
+	"github.com/MichaelMure/git-bug/commands/execenv"
+)
+
+func newWipeCommand() *cobra.Command {
+	env := execenv.NewEnv()
+
+	cmd := &cobra.Command{
+		Use:     "wipe",
+		Short:   "Wipe git-bug from the git repository",
+		PreRunE: execenv.LoadBackend(env),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runWipe(env)
+		},
+	}
+
+	return cmd
+}
+
+func runWipe(env *execenv.Env) error {
+	env.Out.Println("cleaning entities...")
+	err := env.Backend.RemoveAll()
+	if err != nil {
+		_ = env.Backend.Close()
+		return err
+	}
+
+	env.Out.Println("cleaning git config ...")
+	err = env.Backend.ClearUserIdentity()
+	if err != nil {
+		_ = env.Backend.Close()
+		return err
+	}
+	err = env.Backend.LocalConfig().RemoveAll("git-bug")
+	if err != nil {
+		_ = env.Backend.Close()
+		return err
+	}
+
+	storage := env.Backend.LocalStorage()
+
+	err = env.Backend.Close()
+	if err != nil {
+		return err
+	}
+
+	env.Out.Println("cleaning caches ...")
+	err = storage.RemoveAll(".")
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/doc/man/git-bug-wipe.1 b/doc/man/git-bug-wipe.1
new file mode 100644
index 00000000..fc3178f6
--- /dev/null
+++ b/doc/man/git-bug-wipe.1
@@ -0,0 +1,27 @@
+.nh
+.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" ""
+
+.SH NAME
+.PP
+git-bug-wipe - Wipe git-bug from the git repository
+
+
+.SH SYNOPSIS
+.PP
+\fBgit-bug wipe [flags]\fP
+
+
+.SH DESCRIPTION
+.PP
+Wipe git-bug from the git repository
+
+
+.SH OPTIONS
+.PP
+\fB-h\fP, \fB--help\fP[=false]
+	help for wipe
+
+
+.SH SEE ALSO
+.PP
+\fBgit-bug(1)\fP
diff --git a/doc/man/git-bug.1 b/doc/man/git-bug.1
index 8b66d312..9bf59164 100644
--- a/doc/man/git-bug.1
+++ b/doc/man/git-bug.1
@@ -29,4 +29,4 @@ the same git remote you are already using to collaborate with other people.
 
 .SH SEE ALSO
 .PP
-\fBgit-bug-bridge(1)\fP, \fBgit-bug-bug(1)\fP, \fBgit-bug-commands(1)\fP, \fBgit-bug-label(1)\fP, \fBgit-bug-pull(1)\fP, \fBgit-bug-push(1)\fP, \fBgit-bug-termui(1)\fP, \fBgit-bug-user(1)\fP, \fBgit-bug-version(1)\fP, \fBgit-bug-webui(1)\fP
+\fBgit-bug-bridge(1)\fP, \fBgit-bug-bug(1)\fP, \fBgit-bug-commands(1)\fP, \fBgit-bug-label(1)\fP, \fBgit-bug-pull(1)\fP, \fBgit-bug-push(1)\fP, \fBgit-bug-termui(1)\fP, \fBgit-bug-user(1)\fP, \fBgit-bug-version(1)\fP, \fBgit-bug-webui(1)\fP, \fBgit-bug-wipe(1)\fP
diff --git a/doc/md/git-bug.md b/doc/md/git-bug.md
index b311aff5..a71d6dfb 100644
--- a/doc/md/git-bug.md
+++ b/doc/md/git-bug.md
@@ -34,4 +34,5 @@ git-bug [flags]
 * [git-bug user](git-bug_user.md)	 - List identities
 * [git-bug version](git-bug_version.md)	 - Show git-bug version information
 * [git-bug webui](git-bug_webui.md)	 - Launch the web UI
+* [git-bug wipe](git-bug_wipe.md)	 - Wipe git-bug from the git repository
 
diff --git a/doc/md/git-bug_wipe.md b/doc/md/git-bug_wipe.md
new file mode 100644
index 00000000..be1a7246
--- /dev/null
+++ b/doc/md/git-bug_wipe.md
@@ -0,0 +1,18 @@
+## git-bug wipe
+
+Wipe git-bug from the git repository
+
+```
+git-bug wipe [flags]
+```
+
+### Options
+
+```
+  -h, --help   help for wipe
+```
+
+### SEE ALSO
+
+* [git-bug](git-bug.md)	 - A bug tracker embedded in Git
+
diff --git a/entities/bug/bug_actions.go b/entities/bug/bug_actions.go
index 198e4ed0..651d24dc 100644
--- a/entities/bug/bug_actions.go
+++ b/entities/bug/bug_actions.go
@@ -37,3 +37,9 @@ func MergeAll(repo repository.ClockedRepo, resolvers entity.Resolvers, remote st
 func Remove(repo repository.ClockedRepo, id entity.Id) error {
 	return dag.Remove(def, repo, id)
 }
+
+// RemoveAll will remove all local bugs.
+// RemoveAll is idempotent.
+func RemoveAll(repo repository.ClockedRepo) error {
+	return dag.RemoveAll(def, repo)
+}
diff --git a/entities/identity/identity.go b/entities/identity/identity.go
index 22ce652c..91564ffa 100644
--- a/entities/identity/identity.go
+++ b/entities/identity/identity.go
@@ -164,56 +164,6 @@ func ListLocalIds(repo repository.Repo) ([]entity.Id, error) {
 	return entity.RefsToIds(refs), nil
 }
 
-// RemoveIdentity will remove a local identity from its entity.Id
-func RemoveIdentity(repo repository.ClockedRepo, id entity.Id) error {
-	var fullMatches []string
-
-	refs, err := repo.ListRefs(identityRefPattern + id.String())
-	if err != nil {
-		return err
-	}
-	if len(refs) > 1 {
-		return entity.NewErrMultipleMatch(Typename, entity.RefsToIds(refs))
-	}
-	if len(refs) == 1 {
-		// we have the identity locally
-		fullMatches = append(fullMatches, refs[0])
-	}
-
-	remotes, err := repo.GetRemotes()
-	if err != nil {
-		return err
-	}
-
-	for remote := range remotes {
-		remotePrefix := fmt.Sprintf(identityRemoteRefPattern+id.String(), remote)
-		remoteRefs, err := repo.ListRefs(remotePrefix)
-		if err != nil {
-			return err
-		}
-		if len(remoteRefs) > 1 {
-			return entity.NewErrMultipleMatch(Typename, entity.RefsToIds(refs))
-		}
-		if len(remoteRefs) == 1 {
-			// found the identity in a remote
-			fullMatches = append(fullMatches, remoteRefs[0])
-		}
-	}
-
-	if len(fullMatches) == 0 {
-		return entity.NewErrNotFound(Typename)
-	}
-
-	for _, ref := range fullMatches {
-		err = repo.RemoveRef(ref)
-		if err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
 // ReadAllLocal read and parse all local Identity
 func ReadAllLocal(repo repository.ClockedRepo) <-chan entity.StreamedEntity[*Identity] {
 	return readAll(repo, identityRefPattern)
@@ -238,6 +188,9 @@ func readAll(repo repository.ClockedRepo, refPrefix string) <-chan entity.Stream
 			return
 		}
 
+		total := int64(len(refs))
+		current := int64(1)
+
 		for _, ref := range refs {
 			i, err := read(repo, ref)
 
@@ -246,7 +199,12 @@ func readAll(repo repository.ClockedRepo, refPrefix string) <-chan entity.Stream
 				return
 			}
 
-			out <- entity.StreamedEntity[*Identity]{Entity: i}
+			out <- entity.StreamedEntity[*Identity]{
+				Entity:        i,
+				CurrentEntity: current,
+				TotalEntities: total,
+			}
+			current++
 		}
 	}()
 
diff --git a/entities/identity/identity_actions.go b/entities/identity/identity_actions.go
index 13776078..07560dc0 100644
--- a/entities/identity/identity_actions.go
+++ b/entities/identity/identity_actions.go
@@ -123,3 +123,74 @@ func MergeAll(repo repository.ClockedRepo, remote string) <-chan entity.MergeRes
 
 	return out
 }
+
+// Remove will remove a local identity from its entity.Id.
+// It is left as a responsibility to the caller to make sure that this identities is not
+// linked from another entity, otherwise it would break it.
+// Remove is idempotent.
+func Remove(repo repository.ClockedRepo, id entity.Id) error {
+	var fullMatches []string
+
+	refs, err := repo.ListRefs(identityRefPattern + id.String())
+	if err != nil {
+		return err
+	}
+	if len(refs) > 1 {
+		return entity.NewErrMultipleMatch(Typename, entity.RefsToIds(refs))
+	}
+	if len(refs) == 1 {
+		// we have the identity locally
+		fullMatches = append(fullMatches, refs[0])
+	}
+
+	remotes, err := repo.GetRemotes()
+	if err != nil {
+		return err
+	}
+
+	for remote := range remotes {
+		remotePrefix := fmt.Sprintf(identityRemoteRefPattern+id.String(), remote)
+		remoteRefs, err := repo.ListRefs(remotePrefix)
+		if err != nil {
+			return err
+		}
+		if len(remoteRefs) > 1 {
+			return entity.NewErrMultipleMatch(Typename, entity.RefsToIds(refs))
+		}
+		if len(remoteRefs) == 1 {
+			// found the identity in a remote
+			fullMatches = append(fullMatches, remoteRefs[0])
+		}
+	}
+
+	if len(fullMatches) == 0 {
+		return entity.NewErrNotFound(Typename)
+	}
+
+	for _, ref := range fullMatches {
+		err = repo.RemoveRef(ref)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// RemoveAll will remove all local identities.
+// It is left as a responsibility to the caller to make sure that those identities are not
+// linked from another entity, otherwise it would break them.
+// RemoveAll is idempotent.
+func RemoveAll(repo repository.ClockedRepo) error {
+	localIds, err := ListLocalIds(repo)
+	if err != nil {
+		return err
+	}
+	for _, id := range localIds {
+		err = Remove(repo, id)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/entities/identity/identity_user.go b/entities/identity/identity_user.go
index 7eb374d4..f9e39bb2 100644
--- a/entities/identity/identity_user.go
+++ b/entities/identity/identity_user.go
@@ -15,6 +15,10 @@ func SetUserIdentity(repo repository.RepoConfig, identity *Identity) error {
 	return repo.LocalConfig().StoreString(identityConfigKey, identity.Id().String())
 }
 
+func ClearUserIdentity(repo repository.RepoConfig) error {
+	return repo.LocalConfig().RemoveAll(identityConfigKey)
+}
+
 // GetUserIdentity read the current user identity, set with a git config entry
 func GetUserIdentity(repo repository.Repo) (*Identity, error) {
 	id, err := GetUserIdentityId(repo)
diff --git a/entity/dag/entity.go b/entity/dag/entity.go
index 2028e1b4..f8dbd53d 100644
--- a/entity/dag/entity.go
+++ b/entity/dag/entity.go
@@ -314,6 +314,9 @@ func ReadAll[EntityT entity.Interface](def Definition, wrapper func(e *Entity) E
 			return
 		}
 
+		total := int64(len(refs))
+		current := int64(1)
+
 		for _, ref := range refs {
 			e, err := read[EntityT](def, wrapper, repo, resolvers, ref)
 
@@ -322,7 +325,12 @@ func ReadAll[EntityT entity.Interface](def Definition, wrapper func(e *Entity) E
 				return
 			}
 
-			out <- entity.StreamedEntity[EntityT]{Entity: e}
+			out <- entity.StreamedEntity[EntityT]{
+				Entity:        e,
+				CurrentEntity: current,
+				TotalEntities: total,
+			}
+			current++
 		}
 	}()
 
diff --git a/entity/dag/entity_actions.go b/entity/dag/entity_actions.go
index 97a68c36..5f0abec3 100644
--- a/entity/dag/entity_actions.go
+++ b/entity/dag/entity_actions.go
@@ -258,3 +258,19 @@ func Remove(def Definition, repo repository.ClockedRepo, id entity.Id) error {
 
 	return nil
 }
+
+// RemoveAll delete all Entity matching the Definition.
+// RemoveAll is idempotent.
+func RemoveAll(def Definition, repo repository.ClockedRepo) error {
+	localIds, err := ListLocalIds(def, repo)
+	if err != nil {
+		return err
+	}
+	for _, id := range localIds {
+		err = Remove(def, repo, id)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/entity/streamed.go b/entity/streamed.go
index 789224a3..33124ef0 100644
--- a/entity/streamed.go
+++ b/entity/streamed.go
@@ -1,6 +1,11 @@
 package entity
 
 type StreamedEntity[EntityT Interface] struct {
-	Entity EntityT
 	Err    error
+	Entity EntityT
+
+	// CurrentEntity is the index of the current entity being streamed, to express progress.
+	CurrentEntity int64
+	// TotalEntities is the total count of expected entities, if known.
+	TotalEntities int64
 }
diff --git a/go.mod b/go.mod
index 57896eb3..e2425072 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/awesome-gocui/gocui v1.1.0
 	github.com/blevesearch/bleve v1.0.14
 	github.com/cheekybits/genny v1.0.0
-	github.com/dustin/go-humanize v1.0.0
+	github.com/dustin/go-humanize v1.0.1
 	github.com/fatih/color v1.13.0
 	github.com/go-git/go-billy/v5 v5.4.0
 	github.com/go-git/go-git/v5 v5.5.2
@@ -26,6 +26,7 @@ require (
 	github.com/skratchdot/open-golang v0.0.0-20190402232053-79abb63cd66e
 	github.com/spf13/cobra v1.6.1
 	github.com/stretchr/testify v1.8.1
+	github.com/vbauerster/mpb/v8 v8.1.4
 	github.com/vektah/gqlparser/v2 v2.5.1
 	github.com/xanzy/go-gitlab v0.77.0
 	golang.org/x/crypto v0.5.0
@@ -35,7 +36,12 @@ require (
 	golang.org/x/text v0.6.0
 )
 
+// https://github.com/go-git/go-git/pull/659
+replace github.com/go-git/go-git/v5 => github.com/MichaelMure/go-git/v5 v5.1.1-0.20230114115943-17400561a81c
+
 require (
+	github.com/VividCortex/ewma v1.2.0 // indirect
+	github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
 	github.com/cloudflare/circl v1.3.1 // indirect
 	github.com/lithammer/dedent v1.1.0 // indirect
 	github.com/owenrumney/go-sarif v1.0.11 // indirect
@@ -86,14 +92,14 @@ require (
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.0.3 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
-	github.com/mattn/go-runewidth v0.0.12 // indirect
+	github.com/mattn/go-runewidth v0.0.14 // indirect
 	github.com/mitchellh/mapstructure v1.4.1 // indirect
 	github.com/mschoch/smat v0.2.0 // indirect
 	github.com/mtibben/percent v0.2.1 // indirect
 	github.com/philhofer/fwd v1.0.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/praetorian-inc/gokart v0.5.1
-	github.com/rivo/uniseg v0.1.0 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/sergi/go-diff v1.2.0 // indirect
 	github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect
diff --git a/go.sum b/go.sum
index 980af4bb..6c735e9e 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,8 @@ github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
 github.com/Masterminds/glide v0.13.2/go.mod h1:STyF5vcenH/rUqTEv+/hBXlSTo7KYwg2oc2f4tzPWic=
 github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
 github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=
+github.com/MichaelMure/go-git/v5 v5.1.1-0.20230114115943-17400561a81c h1:JFFZbyq4cdKo+QrKNxXemMftPy08aS9gELrPTlPTaZU=
+github.com/MichaelMure/go-git/v5 v5.1.1-0.20230114115943-17400561a81c/go.mod h1:BE5hUJ5yaV2YMxhmaP4l6RBQ08kMxKSPD4BlxtH7OjI=
 github.com/MichaelMure/go-term-text v0.3.1 h1:Kw9kZanyZWiCHOYu9v/8pWEgDQ6UVN9/ix2Vd2zzWf0=
 github.com/MichaelMure/go-term-text v0.3.1/go.mod h1:QgVjAEDUnRMlzpS6ky5CGblux7ebeiLnuy9dAaFZu8o=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
@@ -19,6 +21,10 @@ github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 h1:ra2OtmuW0A
 github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4/go.mod h1:UBYPn8k0D56RtnR8RFQMjmh4KrZzWJ5o7Z9SYjossQ8=
 github.com/RoaringBitmap/roaring v0.4.23 h1:gpyfd12QohbqhFO4NVDUdoPOCXsyahYRQhINmlHxKeo=
 github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
+github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
+github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
+github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
+github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
 github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
 github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
@@ -94,8 +100,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
 github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
-github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
 github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -123,8 +129,6 @@ github.com/go-git/go-billy/v5 v5.4.0 h1:Vaw7LaSTRJOUric7pe4vnzBSgyuf2KrLsu2Y4ZpQ
 github.com/go-git/go-billy/v5 v5.4.0/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
 github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ=
 github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
-github.com/go-git/go-git/v5 v5.5.2 h1:v8lgZa5k9ylUw+OR/roJHTxR4QItsNFI5nKtAXFuynw=
-github.com/go-git/go-git/v5 v5.5.2/go.mod h1:BE5hUJ5yaV2YMxhmaP4l6RBQ08kMxKSPD4BlxtH7OjI=
 github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
 github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -209,8 +213,9 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
 github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
 github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.3.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
@@ -243,8 +248,9 @@ github.com/praetorian-inc/gokart v0.5.1 h1:GYUM69qskrRibZUAEwKEm/pd/j/SFzlFnQnhx
 github.com/praetorian-inc/gokart v0.5.1/go.mod h1:GuA97YgdXwqOVsnHY6PCvV1t9t0Jsk3Zcd6sbTXj4uI=
 github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -298,6 +304,8 @@ github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDW
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
 github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4=
 github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY=
+github.com/vbauerster/mpb/v8 v8.1.4 h1:MOcLTIbbAA892wVjRiuFHa1nRlNvifQMDVh12Bq/xIs=
+github.com/vbauerster/mpb/v8 v8.1.4/go.mod h1:2fRME8lCLU9gwJwghZb1bO9A3Plc8KPeQ/ayGj+Ek4I=
 github.com/vektah/gqlparser/v2 v2.5.1 h1:ZGu+bquAY23jsxDRcYpWjttRZrUz07LbiY77gUOHcr4=
 github.com/vektah/gqlparser/v2 v2.5.1/go.mod h1:mPgqFBu/woKTVYWyNk8cO3kh4S/f4aRFZrvOnp3hmCs=
 github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
diff --git a/repository/gogit.go b/repository/gogit.go
index 01c47d41..93806026 100644
--- a/repository/gogit.go
+++ b/repository/gogit.go
@@ -13,7 +13,6 @@ import (
 	"time"
 
 	"github.com/ProtonMail/go-crypto/openpgp"
-	"github.com/go-git/go-billy/v5"
 	"github.com/go-git/go-billy/v5/osfs"
 	gogit "github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/config"
@@ -48,7 +47,7 @@ type GoGitRepo struct {
 	indexes      map[string]Index
 
 	keyring      Keyring
-	localStorage billy.Filesystem
+	localStorage LocalStorage
 }
 
 // OpenGoGitRepo opens an already existing repo at the given path and
@@ -77,7 +76,7 @@ func OpenGoGitRepo(path, namespace string, clockLoaders []ClockLoader) (*GoGitRe
 		clocks:       make(map[string]lamport.Clock),
 		indexes:      make(map[string]Index),
 		keyring:      k,
-		localStorage: osfs.New(filepath.Join(path, namespace)),
+		localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
 	}
 
 	loaderToRun := make([]ClockLoader, 0, len(clockLoaders))
@@ -131,7 +130,7 @@ func InitGoGitRepo(path, namespace string) (*GoGitRepo, error) {
 		clocks:       make(map[string]lamport.Clock),
 		indexes:      make(map[string]Index),
 		keyring:      k,
-		localStorage: osfs.New(filepath.Join(path, ".git", namespace)),
+		localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, ".git", namespace))},
 	}, nil
 }
 
@@ -156,7 +155,7 @@ func InitBareGoGitRepo(path, namespace string) (*GoGitRepo, error) {
 		clocks:       make(map[string]lamport.Clock),
 		indexes:      make(map[string]Index),
 		keyring:      k,
-		localStorage: osfs.New(filepath.Join(path, namespace)),
+		localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
 	}, nil
 }
 
@@ -320,7 +319,7 @@ func (repo *GoGitRepo) GetRemotes() (map[string]string, error) {
 
 // LocalStorage returns a billy.Filesystem giving access to
 // $RepoPath/.git/$Namespace.
-func (repo *GoGitRepo) LocalStorage() billy.Filesystem {
+func (repo *GoGitRepo) LocalStorage() LocalStorage {
 	return repo.localStorage
 }
 
diff --git a/repository/index_bleve.go b/repository/index_bleve.go
index aae41d5f..40170919 100644
--- a/repository/index_bleve.go
+++ b/repository/index_bleve.go
@@ -129,6 +129,13 @@ func (b *bleveIndex) DocCount() (uint64, error) {
 	return b.index.DocCount()
 }
 
+func (b *bleveIndex) Remove(id string) error {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	return b.index.Delete(id)
+}
+
 func (b *bleveIndex) Clear() error {
 	b.mu.Lock()
 	defer b.mu.Unlock()
diff --git a/repository/localstorage_billy.go b/repository/localstorage_billy.go
new file mode 100644
index 00000000..ab3909c9
--- /dev/null
+++ b/repository/localstorage_billy.go
@@ -0,0 +1,16 @@
+package repository
+
+import (
+	"github.com/go-git/go-billy/v5"
+	"github.com/go-git/go-billy/v5/util"
+)
+
+var _ LocalStorage = &billyLocalStorage{}
+
+type billyLocalStorage struct {
+	billy.Filesystem
+}
+
+func (b billyLocalStorage) RemoveAll(path string) error {
+	return util.RemoveAll(b.Filesystem, path)
+}
diff --git a/repository/mock_repo.go b/repository/mock_repo.go
index c2cef8ef..6ea5c71e 100644
--- a/repository/mock_repo.go
+++ b/repository/mock_repo.go
@@ -9,7 +9,6 @@ import (
 
 	"github.com/99designs/keyring"
 	"github.com/ProtonMail/go-crypto/openpgp"
-	"github.com/go-git/go-billy/v5"
 	"github.com/go-git/go-billy/v5/memfs"
 
 	"github.com/MichaelMure/git-bug/util/lamport"
@@ -123,14 +122,14 @@ func (r *mockRepoCommon) GetRemotes() (map[string]string, error) {
 var _ RepoStorage = &mockRepoStorage{}
 
 type mockRepoStorage struct {
-	localFs billy.Filesystem
+	localFs LocalStorage
 }
 
 func NewMockRepoStorage() *mockRepoStorage {
-	return &mockRepoStorage{localFs: memfs.New()}
+	return &mockRepoStorage{localFs: billyLocalStorage{Filesystem: memfs.New()}}
 }
 
-func (m *mockRepoStorage) LocalStorage() billy.Filesystem {
+func (m *mockRepoStorage) LocalStorage() LocalStorage {
 	return m.localFs
 }
 
@@ -204,6 +203,11 @@ func (m *mockIndex) DocCount() (uint64, error) {
 	return uint64(len(*m)), nil
 }
 
+func (m *mockIndex) Remove(id string) error {
+	delete(*m, id)
+	return nil
+}
+
 func (m *mockIndex) Clear() error {
 	for k, _ := range *m {
 		delete(*m, k)
diff --git a/repository/repo.go b/repository/repo.go
index 66baec65..c39051d5 100644
--- a/repository/repo.go
+++ b/repository/repo.go
@@ -76,10 +76,15 @@ type RepoCommon interface {
 	GetRemotes() (map[string]string, error)
 }
 
+type LocalStorage interface {
+	billy.Filesystem
+	RemoveAll(path string) error
+}
+
 // RepoStorage give access to the filesystem
 type RepoStorage interface {
 	// LocalStorage return a billy.Filesystem giving access to $RepoPath/.git/git-bug
-	LocalStorage() billy.Filesystem
+	LocalStorage() LocalStorage
 }
 
 // RepoIndex gives access to full-text search indexes
@@ -103,6 +108,9 @@ type Index interface {
 	// DocCount returns the number of document in the index.
 	DocCount() (uint64, error)
 
+	// Remove delete one document in the index.
+	Remove(id string) error
+
 	// Clear empty the index.
 	Clear() error
 



Test patch:
diff --git a/cache/repo_cache_test.go b/cache/repo_cache_test.go
index 07a3fee8..3c11220d 100644
--- a/cache/repo_cache_test.go
+++ b/cache/repo_cache_test.go
@@ -135,6 +135,39 @@ func TestCache(t *testing.T) {
 	_, err = cache.Bugs().ResolvePrefix(bug1.Id().String()[:10])
 	require.NoError(t, err)
 
+	require.Len(t, cache.bugs.cached, 1)
+	require.Len(t, cache.bugs.excerpts, 2)
+	require.Len(t, cache.identities.cached, 1)
+	require.Len(t, cache.identities.excerpts, 2)
+	require.Equal(t, uint64(2), indexCount(t, identity.Namespace))
+	require.Equal(t, uint64(2), indexCount(t, bug.Namespace))
+
+	// Remove + RemoveAll
+	err = cache.Identities().Remove(iden1.Id().String()[:10])
+	require.NoError(t, err)
+	err = cache.Bugs().Remove(bug1.Id().String()[:10])
+	require.NoError(t, err)
+	require.Len(t, cache.bugs.cached, 0)
+	require.Len(t, cache.bugs.excerpts, 1)
+	require.Len(t, cache.identities.cached, 0)
+	require.Len(t, cache.identities.excerpts, 1)
+	require.Equal(t, uint64(1), indexCount(t, identity.Namespace))
+	require.Equal(t, uint64(1), indexCount(t, bug.Namespace))
+
+	_, err = cache.Identities().New("René Descartes", "rene@descartes.fr")
+	require.NoError(t, err)
+	_, _, err = cache.Bugs().NewRaw(iden2, time.Now().Unix(), "title", "message", nil, nil)
+	require.NoError(t, err)
+
+	err = cache.RemoveAll()
+	require.NoError(t, err)
+	require.Len(t, cache.bugs.cached, 0)
+	require.Len(t, cache.bugs.excerpts, 0)
+	require.Len(t, cache.identities.cached, 0)
+	require.Len(t, cache.identities.excerpts, 0)
+	require.Equal(t, uint64(0), indexCount(t, identity.Namespace))
+	require.Equal(t, uint64(0), indexCount(t, bug.Namespace))
+
 	// Close
 	require.NoError(t, cache.Close())
 	require.Empty(t, cache.bugs.cached)
diff --git a/commands/execenv/env_testing.go b/commands/execenv/env_testing.go
index 34eafc9c..6eb9c69d 100644
--- a/commands/execenv/env_testing.go
+++ b/commands/execenv/env_testing.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"io"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -12,6 +13,8 @@ import (
 	"github.com/MichaelMure/git-bug/repository"
 )
 
+var _ Out = &TestOut{}
+
 type TestOut struct {
 	*bytes.Buffer
 }
@@ -37,6 +40,10 @@ func (te *TestOut) PrintJSON(v interface{}) error {
 	return nil
 }
 
+func (te *TestOut) Raw() io.Writer {
+	return te.Buffer
+}
+
 func NewTestEnv(t *testing.T) *Env {
 	t.Helper()
 
diff --git a/entities/identity/identity_test.go b/entities/identity/identity_test.go
index 0ecc8058..85d5385b 100644
--- a/entities/identity/identity_test.go
+++ b/entities/identity/identity_test.go
@@ -275,7 +275,7 @@ func TestIdentityRemove(t *testing.T) {
 	_, err = Fetch(repo, "remoteB")
 	require.NoError(t, err)
 
-	err = RemoveIdentity(repo, rene.Id())
+	err = Remove(repo, rene.Id())
 	require.NoError(t, err)
 
 	_, err = ReadLocal(repo, rene.Id())
diff --git a/entity/dag/entity_actions_test.go b/entity/dag/entity_actions_test.go
index fd219644..6181614b 100644
--- a/entity/dag/entity_actions_test.go
+++ b/entity/dag/entity_actions_test.go
@@ -406,3 +406,34 @@ func TestRemove(t *testing.T) {
 	err = Remove(def, repoA, e.Id())
 	require.NoError(t, err)
 }
+
+func TestRemoveAll(t *testing.T) {
+	repoA, _, _, id1, _, resolvers, def := makeTestContextRemote(t)
+
+	var ids []entity.Id
+
+	for i := 0; i < 10; i++ {
+		e := New(def)
+		e.Append(newOp1(id1, "foo"))
+		require.NoError(t, e.Commit(repoA))
+		ids = append(ids, e.Id())
+	}
+
+	_, err := Push(def, repoA, "remote")
+	require.NoError(t, err)
+
+	err = RemoveAll(def, repoA)
+	require.NoError(t, err)
+
+	for _, id := range ids {
+		_, err = Read(def, wrapper, repoA, resolvers, id)
+		require.Error(t, err)
+
+		_, err = readRemote(def, wrapper, repoA, resolvers, "remote", id)
+		require.Error(t, err)
+	}
+
+	// Remove is idempotent
+	err = RemoveAll(def, repoA)
+	require.NoError(t, err)
+}
diff --git a/repository/repo_testing.go b/repository/repo_testing.go
index 821eb762..3dd702f4 100644
--- a/repository/repo_testing.go
+++ b/repository/repo_testing.go
@@ -2,6 +2,7 @@ package repository
 
 import (
 	"math/rand"
+	"os"
 	"testing"
 
 	"github.com/ProtonMail/go-crypto/openpgp"
@@ -10,8 +11,6 @@ import (
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
 
-// TODO: add tests for RepoStorage
-
 type RepoCreator func(t testing.TB, bare bool) TestedRepo
 
 // Test suite for a Repo implementation
@@ -32,6 +31,10 @@ func RepoTest(t *testing.T, creator RepoCreator) {
 				RepoConfigTest(t, repo)
 			})
 
+			t.Run("Storage", func(t *testing.T) {
+				RepoStorageTest(t, repo)
+			})
+
 			t.Run("Index", func(t *testing.T) {
 				RepoIndexTest(t, repo)
 			})
@@ -48,6 +51,36 @@ func RepoConfigTest(t *testing.T, repo RepoConfig) {
 	testConfig(t, repo.LocalConfig())
 }
 
+func RepoStorageTest(t *testing.T, repo RepoStorage) {
+	storage := repo.LocalStorage()
+
+	err := storage.MkdirAll("foo/bar", 0755)
+	require.NoError(t, err)
+
+	f, err := storage.Create("foo/bar/foofoo")
+	require.NoError(t, err)
+
+	_, err = f.Write([]byte("hello"))
+	require.NoError(t, err)
+
+	err = f.Close()
+	require.NoError(t, err)
+
+	// remove all
+	err = storage.RemoveAll(".")
+	require.NoError(t, err)
+
+	fi, err := storage.ReadDir(".")
+	// a real FS would remove the root directory with RemoveAll and subsequent call would fail
+	// a memory FS would still have a virtual root and subsequent call would succeed
+	// not ideal, but will do for now
+	if err == nil {
+		require.Empty(t, fi)
+	} else {
+		require.True(t, os.IsNotExist(err))
+	}
+}
+
 func randomHash() Hash {
 	var letterRunes = "abcdef0123456789"
 	b := make([]byte, idLengthSHA256)